From 896d8625c8a9c2f98c6dd0ed7494f8632b1c2a42 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 26 Jul 2026 00:55:55 -0400 Subject: [PATCH 01/12] feat: keep Claude's five-hour quota window cycling Claude's five-hour window only exists while it runs. Once it lapses the usage API reports no reset time, so the usage screen can only show "unknown" until the next real turn. Codex avoids this because its quota probe is itself a live request; Claude's is a plain read. Send the equivalent minimal Claude Code turn ourselves whenever the window is idle (cheapest model, one output token, same OAuth credential as the usage query), then sleep until just after the new reset moment so the cycle repeats on its own. Never ping on stale usage data, and keep a floor between two pings so a ping that fails to open a window cannot turn into a retry loop. Enabled by default; ENABLE_CLAUDE_KEEPALIVE=false turns it off. Co-Authored-By: Claude Opus 5 --- README.md | 4 +- README.zh-CN.md | 3 +- docs/handbook.md | 8 +++ server/.env.example | 13 ++++ server/lib/quota-keepalive.js | 106 ++++++++++++++++++++++++++++ server/lib/usage.js | 60 ++++++++++++++++ server/server.js | 5 ++ server/test/quota-keepalive.test.js | 65 +++++++++++++++++ 8 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 server/lib/quota-keepalive.js create mode 100644 server/test/quota-keepalive.test.js diff --git a/README.md b/README.md index 81d5695..0881129 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ flowchart LR 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. + queue one prompt for the next detected five-hour reset. The backend keeps + Claude's five-hour window cycling with a minimal request so its reset time is + never unknown. - **Notifications.** Live local/browser alerts plus optional Web Push and Android FCM for configured deployments. diff --git a/README.zh-CN.md b/README.zh-CN.md index 22130a8..4c57a68 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,7 +44,8 @@ flowchart LR 使用后端系统用户运行,并跟随 app 的“白天/黑夜”外观。Web 端内置等宽终端字体, 避免 Chromium 中的字符横向间距过大。 - **额度工作流。** 查看 Claude、Codex 和 Agy 额度;只有 Claude 与 Codex 可以预约在 - 下一个检测到的 5 小时额度重置后自动发送一条消息。 + 下一个检测到的 5 小时额度重置后自动发送一条消息。后端会用一次极小请求让 Claude + 的 5 小时窗口持续滚动,重置时间不再显示为“未知”。 - **通知。** 在线时使用本地/浏览器通知;配置后还可使用 Web Push 和 Android FCM。 ## 快速开始 diff --git a/docs/handbook.md b/docs/handbook.md index 526e5ba..b6885b2 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -179,6 +179,14 @@ The usage screen reports Claude Code, Codex, and Antigravity. Reset detection an scheduled messages support Claude Code and Codex only. A schedule stores one prompt per source and workspace for the next detected five-hour reset. +Claude's five-hour window only exists while it runs: once it lapses the usage API +reports no reset time, which the app can only show as unknown. The backend keeps +the window cycling by sending one minimal Claude Code request (cheapest model, +one output token) whenever the window is idle, then sleeping until just after the +new reset moment — the same effect Codex gets for free from its quota probe. Set +`ENABLE_CLAUDE_KEEPALIVE=false` to turn it off and accept the unknown state; +`CLAUDE_KEEPALIVE_MODEL` overrides the model used for the ping. + Notification delivery has three layers: - Android, iOS, macOS, and Windows can show local notifications while Relay is diff --git a/server/.env.example b/server/.env.example index 73e9073..bca483b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -68,6 +68,19 @@ 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 cheapest model) to restart it, +# then waits for the next reset. Set to false to disable the ping. +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 + # 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())" diff --git a/server/lib/quota-keepalive.js b/server/lib/quota-keepalive.js new file mode 100644 index 0000000..617f15a --- /dev/null +++ b/server/lib/quota-keepalive.js @@ -0,0 +1,106 @@ +'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". Mirrors what Codex gets for free from its header probe. +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/usage.js b/server/lib/usage.js index c8abe66..1ca78a6 100644 --- a/server/lib/usage.js +++ b/server/lib/usage.js @@ -12,8 +12,16 @@ 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 the +// cheapest model and the smallest possible completion. +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'); @@ -362,6 +370,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 cheapest model, using the +// same OAuth credential and Claude Code identity as the usage query above. +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, agy: () => agyCache }; + 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 } }; @@ -947,7 +1005,9 @@ module.exports = { getClaudeUsage, getCodexUsage, getAgyUsage, + invalidateUsageCache, normalizeAgyQuotaSummary, markExpiredQuotas, + primeClaudeSession, buildUsageReport, }; diff --git a/server/server.js b/server/server.js index a9b663a..81d2f64 100644 --- a/server/server.js +++ b/server/server.js @@ -55,6 +55,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 { @@ -112,6 +113,7 @@ 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 @@ -1015,5 +1017,8 @@ const server = app.listen(PORT, HOST, () => { }, }); } + if (ENABLE_CLAUDE_KEEPALIVE) { + startClaudeQuotaKeepalive(); + } }); terminalManager.attachServer(server); 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); +}); From b4b39571f76ee09fcc0596dd98a42b7016e5bd10 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Mon, 27 Jul 2026 22:15:35 -0400 Subject: [PATCH 02/12] chore: add license, CI, Linux service scripts, and backend policy tests Closes the gaps found in a workspace and documentation review. - Test the file API access policy, the device-token store, and quota schedules (server suite: 136 -> 190 tests). tokens.js and quota-schedules.js take RELAY_*_FILE overrides so the modules are testable without touching deployment state, following the existing RELAY_HISTORY_FILE pattern. The file API deny list now reads the token path from tokens.js instead of assuming the default location, so the new override cannot move the token store out from under it. - Add an MIT LICENSE and a GitHub Actions workflow running the analyzer and both test suites on pull requests. - Add Linux start/stop/status/uninstall scripts so all three backend operating systems have the same entry points; uninstall removes the PM2 processes and leaves backend data in place. - Ignore release artifacts so a stray `git add -A` cannot commit an 80 MB APK. - Bump to 0.1.5 across pubspec.yaml, server/package.json, the settings screen constant, and CHANGELOG.md, and record the four locations in AGENTS.md so they stop drifting apart. - Document the remaining supported environment variables, and correct the claim that the credential passphrase is only ever entered interactively. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 57 +++ .gitignore | 8 + AGENTS.md | 12 +- CHANGELOG.md | 23 ++ LICENSE | 21 ++ README.md | 7 +- README.zh-CN.md | 7 +- SECURITY.md | 5 +- backends/README.md | 14 +- backends/README.zh-CN.md | 13 +- backends/linux/lib/common.sh | 56 +++ backends/linux/start.sh | 23 ++ backends/linux/status.sh | 33 ++ backends/linux/stop.sh | 21 ++ backends/linux/uninstall.sh | 35 ++ docs/handbook.md | 4 +- .../settings/app_settings_screen.dart | 3 +- pubspec.yaml | 2 +- server/.env.example | 19 + server/lib/filesystem.js | 5 +- server/lib/quota-schedules.js | 4 +- server/lib/tokens.js | 5 +- server/package.json | 2 +- server/test/filesystem.test.js | 344 ++++++++++++++++++ server/test/quota-schedules.test.js | 228 ++++++++++++ server/test/tokens.test.js | 276 ++++++++++++++ 26 files changed, 1214 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 backends/linux/lib/common.sh create mode 100755 backends/linux/start.sh create mode 100755 backends/linux/status.sh create mode 100755 backends/linux/stop.sh create mode 100755 backends/linux/uninstall.sh create mode 100644 server/test/filesystem.test.js create mode 100644 server/test/quota-schedules.test.js create mode 100644 server/test/tokens.test.js 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..e9adc14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,10 @@ clients and bundles CanvasKit locally instead of depending on gstatic. agent login, 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. @@ -90,6 +93,10 @@ 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`. @@ -145,3 +152,6 @@ override, not a shared catalog. when adding response fields. - Documentation changes: verify local Markdown links, commands, environment names, and English/Chinese README parity 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..4f5598d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.1.5 - 2026-07-27 + +### Added + +- 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. 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 + +- `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`. + ## 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 0881129..843bc01 100644 --- a/README.md +++ b/README.md @@ -155,4 +155,9 @@ Relay/ ``` 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). GitHub Actions runs the analyzer and +both test suites on pull requests. + +## License + +Relay is released under the [MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c57a68..9630611 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -139,4 +139,9 @@ Relay/ ``` 贡献者和编程 agent 请先阅读 [AGENTS.md](AGENTS.md),版本记录见 -[CHANGELOG.md](CHANGELOG.md)。 +[CHANGELOG.md](CHANGELOG.md)。GitHub Actions 会在 pull request 上运行静态分析和 +两套测试。 + +## 许可证 + +Relay 使用 [MIT License](LICENSE) 发布。 diff --git a/SECURITY.md b/SECURITY.md index 7195467..ee6ab95 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,10 @@ 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. Its passphrase is never written to disk. The generator prompts for it +interactively; `--passphrase` and `RELAY_CREDENTIAL_PASSPHRASE` exist for +unattended setup and should be avoided otherwise, because both leave the value +readable in shell history or the process environment. The backend stores bearer-token records and metadata in `server/tokens.json`. That file is a secret and is written owner-only. Native diff --git a/backends/README.md b/backends/README.md index e4e058b..a5b39c4 100644 --- a/backends/README.md +++ b/backends/README.md @@ -45,6 +45,15 @@ app and enter the passphrase you chose. ### 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,7 +62,10 @@ 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). diff --git a/backends/README.zh-CN.md b/backends/README.zh-CN.md index 5e0aa8c..d067cf7 100644 --- a/backends/README.zh-CN.md +++ b/backends/README.zh-CN.md @@ -40,6 +40,15 @@ Relay 在所有后端操作系统上使用同一个 Node.js 服务和同一套 H ### 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,7 +57,9 @@ pm2 logs relay-tunnel ``` Linux 安装需要 PM2(`npm install -g pm2`)。进程名为 `relay-server`;隧道模式还会创建 -`relay-tunnel`。交互终端的 PTY 依赖会在 Linux 上本地编译,因此首次安装还需要 Python 3、 +`relay-tunnel`。日志位于 `~/.pm2/logs/`,文件名为 `relay-server-*.log` 和 +`relay-tunnel-*.log`。`uninstall.sh` 只删除 PM2 进程,保留后端数据、令牌和凭证。 +交互终端的 PTY 依赖会在 Linux 上本地编译,因此首次安装还需要 Python 3、 `make` 和 C++ 编译器(Debian/Ubuntu 可安装 `build-essential`)。 ### macOS 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 <=3.4.0 <4.0.0" diff --git a/server/.env.example b/server/.env.example index bca483b..13e80e2 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. @@ -80,6 +88,8 @@ CLAUDE_KEEPALIVE_MODEL= # 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: @@ -99,3 +109,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/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/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/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/package.json b/server/package.json index bca0f73..3901f2c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "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", 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/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); +}); From 61bbe3046dfefd4e0d73285f49c66d31b53b14f9 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 16:02:37 -0400 Subject: [PATCH 03/12] feat(app): drop Antigravity, dedupe quota alerts, speed up QR import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the Antigravity (agy) agent from the client: its model entry, icon, login flow, drawer entry, strings, and tests. SECURITY.md and the backend READMEs drop it from the login-bridge and credential wording. Quota alerts are sent twice on purpose — down the event stream for open sessions and as a push for closed ones — and on the web the service worker shows its copy regardless of focus, so one alert could appear twice. Notifications now carry a tag derived from the alert, and a repeat replaces the first instead of stacking. Credential QR import prefers the platform's own image decoder and falls back to the pure-Dart pipeline on a background isolate. Running the Dart decode on the web blocked the only thread there is, freezing the tab past the point where the scan timeout could still fire. Also stops the agent drawer tile from hiding its ink splash behind a plain decoration, and adds the Flutter migrator's Android Kotlin/DSL flags. --- SECURITY.md | 6 +- android/gradle.properties | 4 + assets/agent_icons/agy.png | Bin 7198 -> 0 bytes backends/README.md | 6 +- backends/README.zh-CN.md | 5 +- lib/core/backend/backend_client.dart | 2 +- lib/core/credentials/qr_image_decoder.dart | 37 ++++++-- lib/core/credentials/qr_image_pixels.dart | 10 +++ .../credentials/qr_image_pixels_stub.dart | 5 ++ lib/core/credentials/qr_image_pixels_web.dart | 57 +++++++++++++ lib/core/credentials/qr_pixels.dart | 19 +++++ lib/core/i18n/app_strings.dart | 7 +- lib/core/models/cli_agent.dart | 14 +--- .../notifications/browser_notifications.dart | 7 +- .../browser_notifications_stub.dart | 1 + .../browser_notifications_web.dart | 8 +- .../notifications/notification_service.dart | 26 +++++- lib/core/widgets/agent_icon.dart | 1 - lib/features/chat/bot_chat_controller.dart | 31 ++++++- lib/features/chat/bot_chat_screen.dart | 18 ++-- lib/features/chat/chat_content.dart | 4 +- lib/features/chat/group_chat_screen.dart | 2 +- .../cli_agents/agent_status_lights.dart | 2 +- .../cli_agents/cli_agents_drawer.dart | 79 ++++++++++-------- .../machines/agent_login_flow_controller.dart | 13 --- .../machines/deploy_backend_screen.dart | 4 +- .../machines/machine_credentials_screen.dart | 55 ++++++------ pubspec.lock | 8 +- test/cli_agent_test.dart | 50 ----------- test/models_test.dart | 4 +- test/usage_model_test.dart | 14 ++-- 31 files changed, 305 insertions(+), 194 deletions(-) delete mode 100644 assets/agent_icons/agy.png create mode 100644 lib/core/credentials/qr_image_pixels.dart create mode 100644 lib/core/credentials/qr_image_pixels_stub.dart create mode 100644 lib/core/credentials/qr_image_pixels_web.dart create mode 100644 lib/core/credentials/qr_pixels.dart diff --git a/SECURITY.md b/SECURITY.md index ee6ab95..c5932d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,7 +63,7 @@ Implemented controls include: `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. +The in-app Claude/Codex 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 @@ -110,7 +110,7 @@ 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. @@ -140,7 +140,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 7daa08d832a35fffcc110ab99132d6e90b726700..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7198 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 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 = {}; 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..6cb0aca 100644 --- a/lib/core/i18n/app_strings.dart +++ b/lib/core/i18n/app_strings.dart @@ -316,9 +316,6 @@ class AppStrings { 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'; @@ -510,8 +507,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..ac441ce 100644 --- a/lib/core/models/cli_agent.dart +++ b/lib/core/models/cli_agent.dart @@ -7,9 +7,9 @@ class CliAgent { this.authed = true, bool? usable, String? authKind, - }) : usable = usable ?? + }) : 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'; @@ -21,8 +21,7 @@ class CliAgent { 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), ); @@ -71,7 +70,6 @@ String defaultAuthKindForAgent(String key) { switch (key) { case 'claude': case 'codex': - case 'agy': return 'oauth'; case 'hermes': return 'apiKey'; @@ -99,12 +97,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/bot_chat_controller.dart b/lib/features/chat/bot_chat_controller.dart index 99fe1e4..ca660cc 100644 --- a/lib/features/chat/bot_chat_controller.dart +++ b/lib/features/chat/bot_chat_controller.dart @@ -158,7 +158,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]; @@ -532,6 +532,9 @@ class BotChatController extends ChangeNotifier { // 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 +554,7 @@ class BotChatController extends ChangeNotifier { taskPushEnabled: _taskPushEnabled, ); _pushSynced = true; + _webPushActive = true; } catch (_) { // Best-effort; the next app open retries. } @@ -1770,20 +1774,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..c0cec7a 100644 --- a/lib/features/chat/bot_chat_screen.dart +++ b/lib/features/chat/bot_chat_screen.dart @@ -272,7 +272,7 @@ class _BotChatScreenState extends State Future _showBtw() async { final CliAgent agent = widget.agentsController.activeAgent; - const Set btwAgents = {'claude', 'codex', 'agy'}; + const Set btwAgents = {'claude', 'codex'}; if (!btwAgents.contains(agent.key)) return; final String? sessionId = widget.chatController.activeSessionId; if (widget.chatController.messageCount == 0 || @@ -402,7 +402,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' || @@ -673,7 +673,7 @@ class _BtwButton extends StatelessWidget { return const SizedBox.shrink(); } final String agentKey = agentsController.activeAgent.key; - const Set btwAgents = {'claude', 'codex', 'agy'}; + const Set btwAgents = {'claude', 'codex'}; if (!btwAgents.contains(agentKey)) { return const SizedBox.shrink(); } @@ -1690,18 +1690,13 @@ 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); } @@ -1765,7 +1760,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 && diff --git a/lib/features/chat/chat_content.dart b/lib/features/chat/chat_content.dart index 794d0a1..c52467d 100644 --- a/lib/features/chat/chat_content.dart +++ b/lib/features/chat/chat_content.dart @@ -353,8 +353,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..19c9b9c 100644 --- a/lib/features/cli_agents/agent_status_lights.dart +++ b/lib/features/cli_agents/agent_status_lights.dart @@ -34,7 +34,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 index d6c6fec..075cc8d 100644 --- a/lib/features/machines/agent_login_flow_controller.dart +++ b/lib/features/machines/agent_login_flow_controller.dart @@ -30,17 +30,14 @@ class AgentLoginFlowController extends ChangeNotifier { 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 || @@ -52,7 +49,6 @@ class AgentLoginFlowController extends ChangeNotifier { _url = null; _output = ''; _error = null; - _requiresCode = true; _setPhase(AgentLoginPhase.starting); try { _subscription = _startLogin(agentKey).listen( @@ -69,11 +65,6 @@ class AgentLoginFlowController extends ChangeNotifier { } 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.'; @@ -94,10 +85,6 @@ class AgentLoginFlowController extends ChangeNotifier { 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 || diff --git a/lib/features/machines/deploy_backend_screen.dart b/lib/features/machines/deploy_backend_screen.dart index 35dd2d5..439ad1d 100644 --- a/lib/features/machines/deploy_backend_screen.dart +++ b/lib/features/machines/deploy_backend_screen.dart @@ -260,7 +260,7 @@ const List<_DeployStep> _zhSteps = <_DeployStep>[ _DeployStep( title: '准备一台后端机器', body: '一台你自己的电脑或服务器都行:家里的 PC、Mac,或一台云服务器。' - '先装好 Node.js 18+ 和至少一个 CLI 智能体(Claude Code、Codex、Antigravity 等)。' + '先装好 Node.js 18+ 和至少一个 CLI 智能体(Claude Code、Codex、OpenCode 等)。' '可在主机上登录,兼容的 OAuth agent 也可稍后在 Relay 中登录。', ), _DeployStep( @@ -292,7 +292,7 @@ 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 ' + 'OpenCode, …). Log in on the host, or use Relay later for a ' 'compatible OAuth agent.', ), _DeployStep( diff --git a/lib/features/machines/machine_credentials_screen.dart b/lib/features/machines/machine_credentials_screen.dart index d70bce7..dc8c88a 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'; @@ -206,15 +208,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); } @@ -739,7 +753,6 @@ class _AgentLoginDialogState extends State<_AgentLoginDialog> { 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( @@ -758,11 +771,7 @@ class _AgentLoginDialogState extends State<_AgentLoginDialog> { Text(_statusText(strings)), if (_flow.url != null && _flow.url!.isNotEmpty) ...[ const SizedBox(height: 12), - Text( - requiresCode - ? strings.agentLoginOpenUrl - : strings.agentLoginBrowserOpenUrl, - ), + Text(strings.agentLoginOpenUrl), const SizedBox(height: 8), DecoratedBox( decoration: BoxDecoration( @@ -792,18 +801,16 @@ class _AgentLoginDialogState extends State<_AgentLoginDialog> { ), ), ], - 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()), + 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( @@ -841,7 +848,7 @@ class _AgentLoginDialogState extends State<_AgentLoginDialog> { onPressed: _close, child: Text(done ? strings.close : strings.cancel), ), - if (!done && requiresCode) + if (!done) FilledButton( onPressed: _flow.canSubmitCode && hasCode && !submitting ? () => unawaited(_submit()) @@ -862,9 +869,7 @@ class _AgentLoginDialogState extends State<_AgentLoginDialog> { AgentLoginPhase.starting => strings.agentLoginStarting, AgentLoginPhase.waitingForUrl => strings.agentLoginWaitingForUrl, - AgentLoginPhase.readyForCode => _flow.requiresCode - ? strings.agentLoginOpenUrl - : strings.agentLoginBrowserOpenUrl, + AgentLoginPhase.readyForCode => strings.agentLoginOpenUrl, AgentLoginPhase.submitting => strings.agentLoginSubmitting, AgentLoginPhase.done => strings.agentLoginDone, AgentLoginPhase.error => strings.agentLoginFailed( diff --git a/pubspec.lock b/pubspec.lock index 132e3aa..3309b9e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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/test/cli_agent_test.dart b/test/cli_agent_test.dart index 9c0990f..a33d4e8 100644 --- a/test/cli_agent_test.dart +++ b/test/cli_agent_test.dart @@ -162,54 +162,4 @@ void main() { expect(controller.phase, AgentLoginPhase.error); expect(controller.error, 'could not start'); }); - - 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; - }, - ); - 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, - }, - ), - ); - events.add( - const BackendEvent( - type: 'login_url', - data: { - 'sessionId': 's1', - 'agent': 'agy', - 'requiresCode': false, - 'url': 'https://accounts.google.com/o/oauth2/auth', - }, - ), - ); - 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/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); }); }); From 7a5a208aaaf9da0efc51dbc93b22750379c5e5ae Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 16:02:59 -0400 Subject: [PATCH 04/12] feat(server): run every agent as a persistent session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay ran one CLI process per turn, so anything an agent started in the background died the moment the turn ended, and every turn paid the CLI's cold start. Each agent now keeps a live session that turns are fed into, the way a terminal session does. - Claude Code uses the Agent SDK's streaming-input mode: one process per conversation (claude-session-pool.js). - OpenCode, Hermes and Codex speak line-delimited JSON-RPC on stdio — `acp` for the first two, `app-server` for Codex. stdio-agent-pool.js owns the process, the wire, the session cap, idle eviction and cancellation; acp-session-pool.js and codex-session-pool.js supply the protocols. One process per agent hosts every chat, since each session carries its own workdir, so a large startup cost is paid once instead of once per chat. Cold turn to warm turn, measured locally: 3.1s -> 1.5s (claude), 3.9s -> 1.4s (opencode), 5.2s -> 1.2s (hermes), 3.7s -> 1.4s (codex). Cancelling a turn now interrupts it instead of killing the conversation, and OpenCode and Hermes replies stream token by token, which neither could do before. Every pool is a cache: the session id in agent-sessions.json stays authoritative, so a scope without a live session cold-starts by resuming it and behaves exactly as before. Idle sessions close and a cap bounds memory (RELAY_CLAUDE_*, RELAY_AGENT_*); turns past the cap wait for a slot. Background work started by a turn now outlives it, except on Codex, whose sandbox kills each command's process group as the command returns — there it survives only if it detaches with setsid. Deleting or clearing a conversation now deletes the CLI-side transcript, so a deleted conversation cannot be resumed and does not linger on disk. Codex's /btw fork uses the CLI's own thread/fork instead of copying rows and rollout files inside ~/.codex/state_5.sqlite, removing that version-specific surgery. Approval requests reach Relay directly now; until there is an approval UI the bypass tiers approve them and the cautious tiers refuse, which is deterministic where the old non-interactive runs could stall. Also completes the server-side Antigravity removal, and ignores *.bak under server/ so backups of the state files stay as uncommittable as the originals. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 53 +- CHANGELOG.md | 59 + README.md | 20 +- README.zh-CN.md | 17 +- docs/handbook.md | 50 +- server/.env.example | 26 +- server/.gitignore | 3 + server/lib/acp-session-pool.js | 156 +++ server/lib/agent-login.js | 71 -- server/lib/agent-options.js | 205 ++-- server/lib/agent-status.js | 15 - server/lib/agent-turn.js | 2 +- server/lib/agents.js | 1336 +++++++--------------- server/lib/agy-paths.js | 25 - server/lib/claude-session-pool.js | 446 ++++++++ server/lib/codex-session-pool.js | 192 ++++ server/lib/groups.js | 2 +- server/lib/model-discovery.js | 33 - server/lib/stdio-agent-pool.js | 706 ++++++++++++ server/lib/subscription-store.js | 9 +- server/lib/usage.js | 343 +----- server/package-lock.json | 944 ++++++++++++++- server/package.json | 3 +- server/routes/btw.js | 15 +- server/routes/chat.js | 23 +- server/routes/group.js | 16 +- server/routes/meta.js | 4 +- server/routes/sessions.js | 14 +- server/server.js | 17 +- server/test/acp-session-pool.test.js | 389 +++++++ server/test/agent-login.test.js | 52 +- server/test/agent-options.test.js | 97 +- server/test/agent-status.test.js | 28 +- server/test/agy-args.test.js | 98 -- server/test/agy-transcript.test.js | 113 -- server/test/btw.test.js | 59 +- server/test/claude-session-pool.test.js | 318 +++++ server/test/codex-session-pool.test.js | 254 ++++ server/test/fixtures/fake-acp-agent.js | 240 ++++ server/test/fixtures/fake-codex-agent.js | 223 ++++ server/test/group-route.test.js | 2 +- server/test/group-turn.test.js | 10 +- server/test/groups.test.js | 4 +- server/test/meta-agents.test.js | 5 +- server/test/usage.test.js | 97 +- 45 files changed, 4755 insertions(+), 2039 deletions(-) create mode 100644 server/lib/acp-session-pool.js delete mode 100644 server/lib/agy-paths.js create mode 100644 server/lib/claude-session-pool.js create mode 100644 server/lib/codex-session-pool.js create mode 100644 server/lib/stdio-agent-pool.js create mode 100644 server/test/acp-session-pool.test.js delete mode 100644 server/test/agy-args.test.js delete mode 100644 server/test/agy-transcript.test.js create mode 100644 server/test/claude-session-pool.test.js create mode 100644 server/test/codex-session-pool.test.js create mode 100644 server/test/fixtures/fake-acp-agent.js create mode 100644 server/test/fixtures/fake-codex-agent.js diff --git a/AGENTS.md b/AGENTS.md index e9adc14..f8d9851 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 @@ -75,15 +74,53 @@ clients and bundles CanvasKit locally instead of depending on gstatic. 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. +- 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. +- Codex's /btw fork is the CLI's own `thread/fork`. Do not go back to editing + `~/.codex/state_5.sqlite` or copying rollout files. +- Deleting or clearing a conversation goes through `purgeSession`, not + `clearSession`: for a pooled agent it also deletes the CLI-side transcript so + the conversation is really gone. 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. +- `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. - The in-app OAuth bridge uses the backend host's `script -qfec` PTY utility. Keep the process output redacted and never return credential values. @@ -125,8 +162,8 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - 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. + Codex clones its native persisted conversation 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5598d..f46da70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## 0.1.5 - 2026-07-27 +### 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 + uses the device-code flow. + ### Added - The backend keeps Claude's five-hour quota window cycling with one minimal @@ -16,6 +27,54 @@ ### Changed +- 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 — and changing the model, reasoning effort or + permission tier applies to the live session without restarting anything. + 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`). +- Codex's /btw side chat now branches the conversation with the CLI's own + `thread/fork` instead of copying rows and rollout files inside codex's private + SQLite state, which removes about 180 lines of version-specific surgery + against `~/.codex/state_5.sqlite`. +- Deleting a chat session, clearing it, or resetting its /btw side chat now + deletes the CLI-side transcript as well, so a deleted conversation can no + longer be resumed and no longer lingers on disk. This now covers all four + agents. - `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` diff --git a/README.md b/README.md index 843bc01..82270ef 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ -Relay keeps Claude Code, Codex, Antigravity, OpenCode, and Hermes on the machine +Relay keeps Claude Code, Codex, 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. @@ -20,7 +20,7 @@ generate an encrypted credential, and import it into the clients you trust. ```mermaid flowchart LR C["Phone · Web · Desktop"] -->|"encrypted device credential"| B["Your Relay backend"] - B --> A["Claude Code · Codex · Agy · OpenCode · Hermes"] + B --> A["Claude Code · Codex · OpenCode · Hermes"] B --> F["Your projects and files"] ``` @@ -28,11 +28,15 @@ flowchart LR - **Live agent chat.** Stream replies, cancel turns, preserve multi-part agent updates, and continue long work while switching between conversations. +- **Persistent agent sessions.** Every agent keeps a live CLI session between + messages, the way a terminal does, so follow-up turns skip the cold start, + cancelling a turn interrupts it instead of ending the conversation, and work an + agent starts in the background is still running on the next turn. - **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. +- **Agent status and login.** See installed/authenticated state for all four + agents. Relay can bridge Claude and Codex 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. @@ -42,7 +46,7 @@ flowchart LR 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 +- **Read-only BTW side conversations.** Ask Claude or Codex 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. @@ -50,7 +54,7 @@ flowchart LR 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 +- **Quota workflows.** View Claude and Codex usage. Both can queue one prompt for the next detected five-hour reset. The backend keeps Claude's five-hour window cycling with a minimal request so its reset time is never unknown. @@ -62,7 +66,7 @@ flowchart LR ### 1. Prepare a backend 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 +supported CLI installed. Claude and Codex must be logged in; OpenCode and Hermes provider setup is managed on that host. From the repository root, run the setup for the backend OS: diff --git a/README.zh-CN.md b/README.zh-CN.md index 9630611..e48c892 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,7 +9,7 @@ -Relay 让 Claude Code、Codex、Antigravity、OpenCode 和 Hermes 继续运行在已经准备好项目、 +Relay 让 Claude Code、Codex、OpenCode 和 Hermes 继续运行在已经准备好项目、 shell 与登录态的机器上,再通过同一个 Flutter app 从手机、Web 或桌面重新连接这些本地 CLI 智能体,不需要把项目搬到托管服务。 @@ -19,7 +19,7 @@ Relay 没有云端账号,也没有内置的默认后端。你自己运行 Node ```mermaid flowchart LR C["手机 · Web · 桌面"] -->|"加密的设备凭证"| B["你自己的 Relay 后端"] - B --> A["Claude Code · Codex · Agy · OpenCode · Hermes"] + B --> A["Claude Code · Codex · OpenCode · Hermes"] B --> F["你的项目和文件"] ``` @@ -27,10 +27,13 @@ flowchart LR - **实时智能体聊天。** 流式显示回复、取消任务、保留多段 agent 更新;切换会话后长任务 仍可继续运行。 +- **常驻 agent 会话。** 每个 agent 在消息之间保持一个活的 CLI 会话,就像终端里那样: + 后续回合省掉冷启动,取消只是打断本回合而不会结束对话,agent 在后台起的活儿到下一 + 回合还在跑。 - **命名会话。** 每个工作目录与 agent 最多有 8 个持久会话,聊天历史和运行状态可在 多设备间同步。 -- **Agent 状态与登录。** 查看五种 agent 的安装和认证状态。兼容的后端可为 Claude、 - Codex、Agy 中转 OAuth;OpenCode 与 Hermes 的密钥仍由后端主机管理。 +- **Agent 状态与登录。** 查看四种 agent 的安装和认证状态。兼容的后端可为 Claude、 + Codex 中转 OAuth;OpenCode 与 Hermes 的密钥仍由后端主机管理。 - **按 agent 配置。** 在输入区选择模型、思考深度和权限。Claude Code 与 Codex 还会 显示默认关闭的快速模式;快速响应可能消耗更多额度或产生更高费用。 - **Codex 动态目录。** 从已安装 Codex CLI 的结构化元数据读取模型与每个模型支持的 @@ -38,12 +41,12 @@ flowchart LR - **蜂群。** 多个 agent 共享一份记录;每位成员可设置工作树、模型、思考深度、权限、 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行。 蜂群还可保存和导入 JSON 模板。 -- **只读 BTW 旁路对话。** 向 Claude、Codex 或 Agy 提问而不改变主任务的原生会话。 +- **只读 BTW 旁路对话。** 向 Claude 或 Codex 提问而不改变主任务的原生会话。 - **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 - **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上唯一且可恢复的终端;终端 使用后端系统用户运行,并跟随 app 的“白天/黑夜”外观。Web 端内置等宽终端字体, 避免 Chromium 中的字符横向间距过大。 -- **额度工作流。** 查看 Claude、Codex 和 Agy 额度;只有 Claude 与 Codex 可以预约在 +- **额度工作流。** 查看 Claude 与 Codex 额度;两者都可以预约在 下一个检测到的 5 小时额度重置后自动发送一条消息。后端会用一次极小请求让 Claude 的 5 小时窗口持续滚动,重置时间不再显示为“未知”。 - **通知。** 在线时使用本地/浏览器通知;配置后还可使用 Web Push 和 Android FCM。 @@ -53,7 +56,7 @@ flowchart LR ### 1. 准备后端 准备一台安装了 Node.js 18+ 的 Linux、macOS 或 Windows 主机,并至少安装一个支持的 -CLI。Claude、Codex 和 Agy 需要登录;OpenCode 与 Hermes 的 provider 配置在主机完成。 +CLI。Claude 与 Codex 需要登录;OpenCode 与 Hermes 的 provider 配置在主机完成。 在仓库根目录运行后端系统对应的命令: diff --git a/docs/handbook.md b/docs/handbook.md index e4f280e..2984ef2 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -81,10 +81,10 @@ and pasted JSON. Native clients use platform secure storage. The Web client is subject to browser-origin storage security, so use a private profile on a trusted device. -Relay reports separate installed, authenticated, and usable state for all five -known agents. Claude Code, Codex, and Agy are OAuth-gated. Their in-app login -bridge starts the CLI in a PTY, streams the authorization URL, and accepts a -device code where required. The bridge currently depends on GNU-compatible +Relay reports separate installed, authenticated, and usable state for all four +known agents. Claude Code and Codex are OAuth-gated. Their in-app login bridge +starts the CLI in a PTY, streams the authorization URL, and accepts a device +code. The bridge currently depends on GNU-compatible `script -qfec` (normally Linux); on unsupported hosts, log in directly in a terminal. OpenCode and Hermes credentials remain host-managed; once installed, Relay allows their runner to start and lets the CLI report provider errors. @@ -120,6 +120,34 @@ denylist and `RELAY_FS_ROOTS`. ## Runtime model +### Agent sessions + +No agent is re-spawned per turn. Each keeps a live CLI session that turns are fed +into, the way a terminal session works, so follow-up turns skip the cold start +and cancelling a turn interrupts it instead of ending the conversation. Claude +Code runs one Agent SDK process per conversation; OpenCode, Hermes, and Codex +speak stdio JSON-RPC (`acp` for the first two, `app-server` for Codex), where one +process per agent hosts every chat because each session carries its own workdir. + +Every pool is only a cache. The resumable session id in +`server/agent-sessions.json` stays authoritative, so a chat whose process was +closed reloads into the same conversation on its next turn — the same behaviour +as before, just slower for that one turn. + +Idle sessions are closed and there is a cap on how many stay live, because these +processes are large (roughly 300 MB per Claude process, 360 MB for an opencode +process plus about 130 MB per session, 90 MB for hermes). See `RELAY_CLAUDE_*` +and `RELAY_AGENT_*` in `server/.env.example`. Turns past the cap wait for a slot. +Hermes runs one turn at a time per process, so concurrent Hermes chats queue. + +Work an agent starts in the background now outlives the turn that started it, +except on Codex: its sandbox kills each command's process group as the command +returns, so background work there survives only if it detaches into its own +session (`setsid`). + +Deleting or clearing a conversation deletes the CLI-side transcript too, so a +deleted conversation cannot be resumed and does not linger on disk. + ### Workdirs, conversations, and settings Each device stores its current workdir and sends it in `X-Workdir`. Backend state @@ -143,13 +171,12 @@ Agent controls are capability-aware: |---|---:|---:|---:|---:|---| | Claude Code | yes | yes | yes | yes | OAuth | | Codex | yes | model-specific | yes | yes | OAuth | -| Antigravity | yes | no | yes | no | OAuth | | OpenCode | yes | yes | yes | no | host-managed, optional key | | Hermes | host config/pins | no | yes | no | host-managed key | Fast mode defaults off, may use more quota or cost more, and is visible only in the solo-chat composer. Relay sends an explicit Claude `fastMode` setting or -Codex `service_tier` override on every invocation. Availability still depends on +Codex `serviceTier` on every turn. Availability still depends on the selected model, CLI version, account, and provider. Swarm storage can retain the field, but the current Swarm form exposes only model, effort, and permission. @@ -171,13 +198,14 @@ Swarms can be cleared, updated, deleted, or saved as reusable JSON templates. Templates contain the name, member list, and member configuration; they omit the machine-specific workdir, id, and transcript. -BTW side conversations are read-only and do not modify the main session. Claude -forks through its native CLI; Codex and Agy clone their native persisted -conversation before resuming an isolated side scope. +BTW side conversations are read-only and do not modify the main session. Both +Claude and Codex branch through their own CLI: Claude forks its session, Codex +forks its thread. The fork inherits the main conversation's memory and gets its +own id, so the side question never writes back into the main task. ### Quota and notifications -The usage screen reports Claude Code, Codex, and Antigravity. Reset detection and +The usage screen reports Claude Code and Codex. Reset detection and scheduled messages support Claude Code and Codex only. A schedule stores one prompt per source and workspace for the next detected five-hour reset. @@ -286,6 +314,8 @@ groups are: - execution: `RELAY_DEFAULT_DIR`, `AGENT_TIMEOUT_MS`, `PROMPT_MAX_BYTES`, `RELAY_MODEL_DISCOVERY`, `CODEX_HOME`, `RELAY_TERMINAL_SHELL`, and terminal idle/buffer limits; +- agent sessions: `RELAY_CLAUDE_IDLE_MS`, `RELAY_CLAUDE_MAX_LIVE`, + `RELAY_CLAUDE_BIN`, `RELAY_AGENT_IDLE_MS`, `RELAY_AGENT_MAX_SESSIONS`; - security/files: `CORS_ALLOW_ORIGIN`, `RELAY_FS_ROOTS`, upload/download caps; - usage: quota watch, poll interval, HTTP/probe timeouts and backoff; - offline push: VAPID keys and `FCM_SERVICE_ACCOUNT_FILE`. diff --git a/server/.env.example b/server/.env.example index 13e80e2..4c8e3bb 100644 --- a/server/.env.example +++ b/server/.env.example @@ -41,6 +41,30 @@ RELAY_DEFAULT_DIR= # Max runtime for one CLI agent turn. Default: 3600000 (60 minutes). AGENT_TIMEOUT_MS=3600000 +# Claude runs as a persistent session: one CLI process per chat, kept alive +# between turns like a terminal, so follow-ups skip the cold start and anything +# started in the background keeps running. Each live process costs roughly +# 300 MB, so idle ones are closed and there is a hard cap on how many exist at +# once; a chat whose process was closed simply resumes on its next turn. +# A chat and its /btw side chat count as two. 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 +# cost of booting the CLI — around 360 MB for opencode, 90 MB for hermes — 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 simply +# reloads on its next turn. The cap below is per agent and counts live sessions; +# turns past it wait for a slot. Note that hermes runs one turn at a time per +# process, so concurrent hermes chats queue. +# 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 @@ -66,8 +90,6 @@ RELAY_FS_ROOTS= # Hard timeout for outbound quota-usage HTTP requests. Default: 15000 (15s). 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= 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 index 616873e..e28e755 100644 --- a/server/lib/agent-login.js +++ b/server/lib/agent-login.js @@ -2,9 +2,6 @@ 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'); @@ -12,37 +9,18 @@ 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, `'\\''`)}'`; } @@ -102,9 +80,6 @@ function createAgentLoginManager(options = {}) { 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; @@ -112,8 +87,6 @@ function createAgentLoginManager(options = {}) { return { sessionId: session.id, agent: session.agent, - authMode: session.authMode, - requiresCode: session.requiresCode, }; } @@ -182,10 +155,6 @@ function createAgentLoginManager(options = {}) { 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(); @@ -227,15 +196,6 @@ function createAgentLoginManager(options = {}) { } } - 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]; @@ -259,21 +219,13 @@ function createAgentLoginManager(options = {}) { 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'], @@ -286,20 +238,6 @@ function createAgentLoginManager(options = {}) { 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 { @@ -312,7 +250,6 @@ function createAgentLoginManager(options = {}) { ); } }); - if (agent === 'agy') startAgyTokenPoll(session); emit(session, 'login_started'); return session; } @@ -340,11 +277,6 @@ function createAgentLoginManager(options = {}) { 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'; @@ -364,8 +296,6 @@ function createAgentLoginManager(options = {}) { status: session.status, url: session.url, error: session.error, - authMode: session.authMode, - requiresCode: session.requiresCode, }; } @@ -374,7 +304,6 @@ function createAgentLoginManager(options = {}) { module.exports = { LOGIN_COMMANDS, - agyTokenPath, createAgentLoginManager, selectLoginUrl, scriptCommand, diff --git a/server/lib/agent-options.js b/server/lib/agent-options.js index 0434152..87abe04 100644 --- a/server/lib/agent-options.js +++ b/server/lib/agent-options.js @@ -6,7 +6,7 @@ // without knowing agent-specific flag shapes. // // 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)); @@ -435,11 +359,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 +474,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-status.js b/server/lib/agent-status.js index cd34439..5e737d8 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', }; @@ -56,18 +55,6 @@ function codexAuthed(fsModule, homeDir) { return !!(tokens && nonEmpty(tokens.access_token)); } -function agyAuthed(fsModule, homeDir) { - return fileHasText( - fsModule, - path.join( - homeDir, - '.gemini', - 'antigravity-cli', - 'antigravity-oauth-token', - ), - ); -} - function hasApiKeyLikeValue(value, keyName = '') { if (value === null || value === undefined) return false; if (Array.isArray(value)) { @@ -113,8 +100,6 @@ function agentAuthed(agentKey, installed, fsModule, homeDir) { return claudeAuthed(fsModule, homeDir); case 'codex': return codexAuthed(fsModule, homeDir); - case 'agy': - return agyAuthed(fsModule, homeDir); case 'hermes': return hermesAuthed(fsModule, homeDir); case 'opencode': 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..a5272cd 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,6 +227,105 @@ function toolBrief(name, input) { return `${name}${detail ? `: ${oneLine(detail, 80)}` : ''}`; } +// 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 really +// delete the machine-side conversation instead of just forgetting its id. +const SESSION_POOLS = { + claude: claudePool, + opencode: opencodePool, + hermes: hermesPool, + codex: codexPool, +}; + +// Delete a scope's conversation for good. Deleting a chat in the app means the +// conversation is gone, so a pooled scope also loses its CLI-side transcript: +// without that, the id would be forgotten while the transcript lingered on +// disk, resumable forever. Other agents just forget the id — their transcripts +// belong to their own CLIs. +async function purgeSession(sessionKey, options = {}) { + const agentKey = String(options.agentKey || '').replace(/^btw:/, ''); + const pool = SESSION_POOLS[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(() => {}), + ), + ); +} + // 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 @@ -441,9 +344,9 @@ function runClaudeInvocation({ }) { const cwd = workdir || getDefaultWorkdir(); 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,90 +356,100 @@ 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()}`; + } + }; + + 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 (canRetry) { + if (sessionKey) clearSession(sessionKey); + return { __retry: true }; } - // 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 }; + } + 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, + // 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. + forkSession, + 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); + }, + ); + + return finishRun(run, { agentKey: 'claude', onEvent, retry }); } function runClaude(prompt, onEvent, sessionKey, signal, workdir, settings) { @@ -596,281 +509,123 @@ function runBtw(prompt, onEvent, options = {}) { 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 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}` : '', + const label = codexItemLabel(event.item); + if (label) emit(onEvent, label); }; - 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;`, - ); - } 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( - 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), - ]; - - 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; + // 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; + }; - 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; + 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.'); } - 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 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)), - }); + 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)' + ); + }, + ); + + return finishRun(run, { agentKey: 'codex', onEvent }); } -function runCodexBtw(prompt, onEvent, options = {}) { +// The /btw sidekick for codex: a read-only side question that inherits the main +// thread's memory without writing back to it. The branch is codex's own +// `thread/fork`; Relay previously had to copy rows and rollout files inside +// codex's private SQLite state to get the same result. +async function runCodexBtw(prompt, onEvent, options = {}) { const { mainSessionKey, btwSessionKey, signal, workdir, settings } = options; const readOnlySettings = { ...(settings || {}), permission: 'read-only' }; const btwPrior = getSession(btwSessionKey); @@ -878,7 +633,11 @@ function runCodexBtw(prompt, onEvent, options = {}) { const mainPrior = getSession(mainSessionKey); const mainThreadId = mainPrior && mainPrior.id ? mainPrior.id : null; if (mainThreadId) { - const childThreadId = cloneCodexThread(mainThreadId); + const childThreadId = await codexPool.driverCall( + 'fork', + mainThreadId, + workdir || getDefaultWorkdir(), + ); setSession(btwSessionKey, { id: childThreadId, parentId: mainThreadId, @@ -886,397 +645,146 @@ function runCodexBtw(prompt, onEvent, options = {}) { }); } } - 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); - } - } + return runCodex(prompt, onEvent, btwSessionKey, signal, workdir, readOnlySettings); +} + +// 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, +}) { + const cwd = workdir || getDefaultWorkdir(); + const prior = getSession(sessionKey); + 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 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 ''; + // 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 }); + }; - 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(); + const onMessage = (update) => { + if (update.sessionUpdate === 'agent_message_chunk') { + const msgId = update.messageId || 'msg'; + if (currentMsgId !== null && msgId !== currentMsgId) { + emit(onEvent, { type: 'segment' }); + } + 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 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 (!text && (isAuthError(stdout) || isAuthError(stderr))) { - return { __authError: true }; - } - return text || fallback(stdout, stderr, code, 'agy'); - }, - }), { agentKey: 'agy', onEvent }); -} + 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 + ); + }, + ); -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); + return finishRun(run, { agentKey, onEvent }); } -// 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 +801,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', @@ -1362,6 +864,10 @@ module.exports = { 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..c111bca --- /dev/null +++ b/server/lib/claude-session-pool.js @@ -0,0 +1,446 @@ +'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.resumeId && request.forkSession + ? { forkSession: true } + : {}), + ...(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; + if (!entry) { + // A restart resumes the conversation it replaced rather than forking it + // a second time. + entry = await spawnEntry( + resumeId === request.resumeId + ? request + : { ...request, resumeId, forkSession: false }, + ); + } + 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. + // The fork (if any) already happened when the session was first spawned, + // so resume into it rather than forking a second time. + const fresh = await spawnEntry({ + ...request, + resumeId, + forkSession: false, + }); + return runTurn(fresh, request); + } + } + + // Drop the live process for a scope. `sessionId` additionally deletes the + // stored transcript, so a session the user deleted can never be resumed. + 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..d6bd2a8 --- /dev/null +++ b/server/lib/codex-session-pool.js @@ -0,0 +1,192 @@ +'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); + }, + + // Branch a thread into a new one that inherits its memory without writing + // back to it — how /btw asks a side question without disturbing the main + // task. Relay used to do this by copying rows and rollout files inside + // codex's private SQLite state; this is the supported operation for it. + async fork(threadId, cwd) { + const forked = await rpc.request('thread/fork', { threadId, cwd }); + const id = forked && forked.thread && forked.thread.id; + if (!id) throw new Error('codex returned no forked thread id'); + return id; + }, + + 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/groups.js b/server/lib/groups.js index 1ea99b6..f0d6159 100644 --- a/server/lib/groups.js +++ b/server/lib/groups.js @@ -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..bfe88c8 100644 --- a/server/lib/model-discovery.js +++ b/server/lib/model-discovery.js @@ -270,33 +270,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,12 +339,6 @@ 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(), - }, }; // Discovered model options for an agent, or null to fall back to the static diff --git a/server/lib/stdio-agent-pool.js b/server/lib/stdio-agent-pool.js new file mode 100644 index 0000000..5d509a0 --- /dev/null +++ b/server/lib/stdio-agent-pool.js @@ -0,0 +1,706 @@ +'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. That matters: opencode costs ~360MB just to boot, and paying that once +// instead of once per chat is the difference between three chats costing 1.5GB +// and costing 750MB. +// +// 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. opencode costs ~130MB per session on top of its +// ~360MB base, so four is roughly the same memory ceiling as the Claude pool's +// three processes. +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. `purge` additionally deletes the agent's + // own stored transcript, so a session the user deleted can never be resumed. + 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); + } + + // Call a protocol operation that is not a turn (codex's thread/fork). The + // connection is opened if needed and released again when nothing is using it. + async function driverCall(name, ...args) { + const c = await ensureConnection(); + const fn = c.driver[name]; + if (!fn) throw new Error(`${agentKey} does not support ${name}`); + try { + return await fn(...args); + } finally { + closeIdleConnection(); + } + } + + function stats() { + return { + live: live.size, + maxSessions, + idleMs, + waiting: slotWaiters.length, + connected: !!(conn && !conn.closed), + keys: [...live.keys()], + }; + } + + return { send, forget, shutdown, stats, driverCall }; +} + +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/usage.js b/server/lib/usage.js index 1ca78a6..014ad91 100644 --- a/server/lib/usage.js +++ b/server/lib/usage.js @@ -3,11 +3,7 @@ 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'; @@ -28,16 +24,8 @@ 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', @@ -67,12 +55,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 @@ -267,9 +253,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; @@ -415,7 +399,7 @@ async function primeClaudeSession() { // 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, agy: () => agyCache }; + const caches = { claude: () => claudeCache, codex: () => codexCache }; const cache = caches[key] && caches[key](); if (cache) cache.at = 0; } @@ -580,310 +564,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))); @@ -902,9 +582,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. @@ -948,18 +627,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 }) { @@ -1004,9 +671,7 @@ async function buildUsageReport() { module.exports = { getClaudeUsage, getCodexUsage, - getAgyUsage, invalidateUsageCache, - normalizeAgyQuotaSummary, 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 3901f2c..2f1d92f 100644 --- a/server/package.json +++ b/server/package.json @@ -7,10 +7,11 @@ "scripts": { "start": "node server.js", "dev": "node server.js", - "test": "node --test", + "test": "node --test \"test/*.test.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/btw.js b/server/routes/btw.js index d682fe5..c4444e1 100644 --- a/server/routes/btw.js +++ b/server/routes/btw.js @@ -6,7 +6,7 @@ const express = require('express'); // 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']); +const BTW_SUPPORTED = new Set(['claude', 'codex']); function btwScopeAgent(agentKey) { return `btw:${agentKey}`; @@ -18,7 +18,7 @@ module.exports = function createBtwRouter(ctx) { activeRequests, agentTurnDependencies, clearHistory, - clearSession, + purgeSession, createChatResponder, finalizeStaleStreamingHistory, normalizeDeviceId, @@ -172,7 +172,7 @@ module.exports = function createBtwRouter(ctx) { // 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) => { + router.post('/api/btw/clear', async (req, res) => { const agentKey = String(req.body.agent || 'claude').trim(); const requestedSessionId = String(req.body.sessionId || '').trim(); const scope = resolveBtwScope(req, res, { @@ -180,13 +180,18 @@ module.exports = function createBtwRouter(ctx) { sessionId: requestedSessionId, }); if (!scope) return; - const { btwScopeKey } = scope; + const { btwScopeKey, workdir } = scope; if (runningScopes.has(btwScopeKey)) { return res .status(409) .json({ error: 'a side question is running', code: 'SESSION_BUSY' }); } - clearSession(btwScopeKey); + // The side chat is its own forked session, so resetting it deletes that + // fork's transcript rather than leaving an unreachable one behind. + await purgeSession(btwScopeKey, { + agentKey: `btw:${agentKey}`, + workdir, + }); clearHistory(btwScopeKey); return res.json({ ok: true }); }); diff --git a/server/routes/chat.js b/server/routes/chat.js index 1c9af65..86ae1bb 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, @@ -246,7 +246,7 @@ 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) => { + 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,13 +275,19 @@ 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); + await purgeSession(btwScopeKey, { + agentKey: `btw:${agent.key}`, + workdir, + }); clearHistory(btwScopeKey); touchChatSession(contextKey, chatSession.id); return res.json({ @@ -297,11 +303,16 @@ 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); + await purgeSession(btwScopeKey, { + agentKey: `btw:${agent.key}`, + workdir, + }); clearHistory(btwScopeKey); } } diff --git a/server/routes/group.js b/server/routes/group.js index 63eb467..ed028eb 100644 --- a/server/routes/group.js +++ b/server/routes/group.js @@ -96,7 +96,7 @@ module.exports = function createGroupRouter(ctx) { activeRequests, agentTurnDependencies, clearHistory, - clearSession, + purgeSession, finalizeStaleStreamingHistory, getAgent, normalizeDeviceId, @@ -229,7 +229,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 +245,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 +270,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 +285,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 }); }); diff --git a/server/routes/meta.js b/server/routes/meta.js index 1e2b401..bc23761 100644 --- a/server/routes/meta.js +++ b/server/routes/meta.js @@ -65,7 +65,7 @@ module.exports = function createMetaRouter(ctx) { installed, authed, authKind: status.authKind || 'unknown', - // claude/codex/agy (oauth) gate on login; hermes/opencode are managed + // 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: @@ -195,7 +195,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..331e70a 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,8 +127,16 @@ module.exports = function createSessionsRouter(ctx) { }); } const result = deleteChatSession(contextKey, sessionId); - clearSession(scopeKey); + await purgeSession(scopeKey, { agentKey: agent.key, workdir }); clearHistory(scopeKey); + // The /btw side chat is a fork of this conversation and has a transcript of + // its own, so deleting the chat has to take it down too. + const btwScopeKey = scopeKeyFor(`btw:${agent.key}`, workdir, sessionId); + await purgeSession(btwScopeKey, { + agentKey: `btw:${agent.key}`, + workdir, + }); + clearHistory(btwScopeKey); return res.json({ ok: true, agent: agentPayload(agent), diff --git a/server/server.js b/server/server.js index 81d2f64..5740ca9 100644 --- a/server/server.js +++ b/server/server.js @@ -19,7 +19,8 @@ const { runAgent, runBtw, runBtwAgent, - clearSession, + purgeSession, + shutdownPools, } = require('./lib/agents'); const { WorkdirError, @@ -830,7 +831,7 @@ const routeContext = { buildUsageReport, cancelQuotaSchedule, clearHistory, - clearSession, + purgeSession, createChatResponder, createChatSession, createQuotaSchedule, @@ -952,6 +953,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)); }); @@ -960,10 +964,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) { @@ -1010,6 +1014,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}`); 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-login.test.js b/server/test/agent-login.test.js index 408e655..f6af3d4 100644 --- a/server/test/agent-login.test.js +++ b/server/test/agent-login.test.js @@ -2,13 +2,9 @@ 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, @@ -48,11 +44,11 @@ test('selectLoginUrl prefers auth URLs over incidental links', () => { 'https://auth.openai.com/oauth/authorize?client_id=codex', ); assert.deepEqual( - selectLoginUrl('agy', [ + selectLoginUrl('claude', [ 'https://example.test/help', - 'https://accounts.google.com/o/oauth2/auth?client_id=agy.', + 'https://claude.ai/oauth/authorize?client_id=claude.', ]).url, - 'https://accounts.google.com/o/oauth2/auth?client_id=agy', + 'https://claude.ai/oauth/authorize?client_id=claude', ); }); @@ -100,48 +96,6 @@ test('login manager streams URL events and writes submitted code to PTY stdin', 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({ 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..3bb07ac 100644 --- a/server/test/agent-status.test.js +++ b/server/test/agent-status.test.js @@ -56,15 +56,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,7 +63,7 @@ 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, { @@ -85,11 +76,6 @@ test('detects installed CLI agents and credential files without exposing values' authed: true, authKind: 'oauth', }); - assert.deepEqual(result.agy, { - installed: true, - authed: true, - authKind: 'oauth', - }); assert.deepEqual(result.hermes, { installed: true, authed: true, @@ -110,27 +96,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 index 52bef6f..5b3e119 100644 --- a/server/test/btw.test.js +++ b/server/test/btw.test.js @@ -38,7 +38,7 @@ function handlerFor(router, method, path) { // 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 cleared = { sessions: [], histories: [], purged: [] }; const reads = []; const finalized = []; const ctx = { @@ -61,8 +61,9 @@ function makeCtx(overrides = {}) { }, }), createChatResponder: () => ({}), - clearSession: (key) => { + purgeSession: async (key, options) => { cleared.sessions.push(key); + cleared.purged.push(options); return true; }, clearHistory: (key) => { @@ -194,60 +195,6 @@ test('btw post uses the native agent side runner for codex without transcript se ); }); -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'), 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..f94d65c --- /dev/null +++ b/server/test/codex-session-pool.test.js @@ -0,0 +1,254 @@ +'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('fork branches a thread through the protocol', async () => { + const { pool, state, done } = makePool(); + const main = await send(pool, 'a', 'one'); + const forked = await pool.driverCall('fork', main.sessionId, '/w'); + assert.notEqual(forked, main.sessionId); + assert.ok(state().includes(`fork ${main.sessionId} -> ${forked}`)); + 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/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..cbc63c5 --- /dev/null +++ b/server/test/fixtures/fake-codex-agent.js @@ -0,0 +1,223 @@ +'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 'thread/fork': { + counter += 1; + const id = `fork-${counter}`; + threads.set(id, { cwd: params.cwd }); + record(`fork ${params.threadId} -> ${id}`); + reply({ thread: { id } }); + 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..b34bd52 100644 --- a/server/test/group-route.test.js +++ b/server/test/group-route.test.js @@ -69,7 +69,7 @@ function buildContext() { activeRequests: new Map(), agentTurnDependencies, clearHistory: history.clearHistory, - clearSession: () => {}, + purgeSession: async () => true, finalizeStaleStreamingHistory: history.finalizeStaleStreamingHistory, getAgent: (key) => AGENTS[key] || null, normalizeDeviceId: () => '', diff --git a/server/test/group-turn.test.js b/server/test/group-turn.test.js index 65147b3..4894336 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), []); }); 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..6462cb6 100644 --- a/server/test/meta-agents.test.js +++ b/server/test/meta-agents.test.js @@ -17,7 +17,6 @@ before(async () => { getAgentStatuses: () => ({ claude: { installed: true, authed: true, authKind: 'oauth' }, codex: { installed: true, authed: false, authKind: 'oauth' }, - agy: { installed: false, authed: false, authKind: 'oauth' }, opencode: { installed: true, authed: true, @@ -28,7 +27,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 +51,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( @@ -80,7 +78,6 @@ test('/api/agents returns every agent with install/auth usability fields', async }, ); assert.equal(byKey.codex.usable, false); - assert.equal(byKey.agy.usable, false); 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/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'); From 94d0e921141fec08af433c22e4a8eb99669e73b4 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 16:05:56 -0400 Subject: [PATCH 05/12] fix(scripts): accept 401 as a healthy answer when restarting the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restart script polled /api/health unauthenticated, but that route sits behind requireAuth, so it could only ever get 401 — the script reported a failed restart every time even though the backend was up. Worse, retrying spent the brute-force guard's budget of 15 auth failures per minute, so the last few attempts came back 429. Any HTTP status now counts as "the process is listening and Express is serving"; only a connection failure or a 5xx keeps the loop waiting. Co-Authored-By: Claude Opus 5 --- scripts/restart_backend.sh | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) 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 } From d6ffd5b8d82fe6b0a95750dc2720fa5bd3b4506e Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 16:18:28 -0400 Subject: [PATCH 06/12] feat: remove BTW side conversations BTW existed only for Claude Code and Codex, and only to ask a read-only side question against a fork of the main conversation. Removing it drops the /api/btw routes, the `btw:` session scopes and their transcripts, the BTW button and dialog in the app, and the per-agent session forking that existed solely to support them: - Claude's runner had been split into runClaudeInvocation + runClaude so BTW could reuse it with forkSession/canRetry; with one caller left they collapse back into runClaude, and the pool loses its forkSession option. - Codex loses thread/fork and the stdio pool loses driverCall, the seam added to reach it. - Deleting or clearing a chat no longer has to take a derived side scope down with it. No stored state needed migrating: there were no btw session ids and no btw history scopes left on this machine. Verified after the removal that all four agents still open, resume and purge a normal chat, and that a follow-up turn still carries memory on the same session id. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 7 +- CHANGELOG.md | 15 +- README.md | 2 - README.zh-CN.md | 1 - docs/handbook.md | 9 +- lib/core/backend/backend_client.dart | 49 ---- lib/core/i18n/app_strings.dart | 12 - lib/features/chat/bot_chat_screen.dart | 87 ------ lib/features/chat/btw_controller.dart | 192 ------------- lib/features/chat/btw_dialog.dart | 325 ----------------------- server/lib/agents.js | 125 +-------- server/lib/claude-session-pool.js | 23 +- server/lib/codex-session-pool.js | 11 - server/lib/stdio-agent-pool.js | 15 +- server/routes/btw.js | 200 -------------- server/routes/chat.js | 16 -- server/routes/sessions.js | 8 - server/server.js | 7 - server/test/btw.test.js | 225 ---------------- server/test/codex-session-pool.test.js | 9 - server/test/fixtures/fake-codex-agent.js | 8 - 21 files changed, 25 insertions(+), 1321 deletions(-) delete mode 100644 lib/features/chat/btw_controller.dart delete mode 100644 lib/features/chat/btw_dialog.dart delete mode 100644 server/routes/btw.js delete mode 100644 server/test/btw.test.js diff --git a/AGENTS.md b/AGENTS.md index f8d9851..d469650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ 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, +- `server/routes/`: API routers for metadata, push, files, chat, Swarms, agent login, sessions, quota, and the SSH terminal ticket. - `server/lib/`: agent runners, settings/model discovery, persistence, auth, filesystem policy, history, quota, push, and orchestration helpers. @@ -104,8 +104,6 @@ clients and bundles CanvasKit locally instead of depending on gstatic. 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. -- Codex's /btw fork is the CLI's own `thread/fork`. Do not go back to editing - `~/.codex/state_5.sqlite` or copying rollout files. - Deleting or clearing a conversation goes through `purgeSession`, not `clearSession`: for a pooled agent it also deletes the CLI-side transcript so the conversation is really gone. Use `clearSession` only for the internal @@ -161,9 +159,6 @@ clients and bundles CanvasKit locally instead of depending on gstatic. members run in parallel from their own delta prompts. - 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 clones its native persisted conversation 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f46da70..a2c3559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - The browser-only OAuth login mode (`authMode` / `requiresCode` on the login SSE stream), which existed solely for Antigravity. Every remaining OAuth agent uses the device-code 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. ### Added @@ -67,14 +71,9 @@ 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`). -- Codex's /btw side chat now branches the conversation with the CLI's own - `thread/fork` instead of copying rows and rollout files inside codex's private - SQLite state, which removes about 180 lines of version-specific surgery - against `~/.codex/state_5.sqlite`. -- Deleting a chat session, clearing it, or resetting its /btw side chat now - deletes the CLI-side transcript as well, so a deleted conversation can no - longer be resumed and no longer lingers on disk. This now covers all four - agents. +- Deleting or clearing a chat session now deletes the CLI-side transcript as + well, so a deleted conversation can no longer be resumed and no longer lingers + on disk. This covers all four agents. - `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` diff --git a/README.md b/README.md index 82270ef..b2abc70 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,6 @@ flowchart LR 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 or Codex 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index e48c892..8a052fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,6 @@ flowchart LR - **蜂群。** 多个 agent 共享一份记录;每位成员可设置工作树、模型、思考深度、权限、 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行。 蜂群还可保存和导入 JSON 模板。 -- **只读 BTW 旁路对话。** 向 Claude 或 Codex 提问而不改变主任务的原生会话。 - **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 - **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上唯一且可恢复的终端;终端 使用后端系统用户运行,并跟随 app 的“白天/黑夜”外观。Web 端内置等宽终端字体, diff --git a/docs/handbook.md b/docs/handbook.md index 2984ef2..4921693 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -28,7 +28,7 @@ the backend OS user. A stable deployment should have all of the following: ### Reverse proxy requirements - Forward normal HTTP requests and long-lived SSE responses. Disable buffering - for `/api/events`, `/api/chat`, `/api/group/chat`, `/api/btw`, and + for `/api/events`, `/api/chat`, `/api/group/chat`, and `/api/agent-auth/login/start`. - Forward `Upgrade`/`Connection` headers for the WebSocket endpoint `/api/terminal/connect`. Do not log its one-time `ticket` query value. @@ -198,11 +198,6 @@ Swarms can be cleared, updated, deleted, or saved as reusable JSON templates. Templates contain the name, member list, and member configuration; they omit the machine-specific workdir, id, and transcript. -BTW side conversations are read-only and do not modify the main session. Both -Claude and Codex branch through their own CLI: Claude forks its session, Codex -forks its thread. The fork inherits the main conversation's memory and gets its -own id, so the side question never writes back into the main task. - ### Quota and notifications The usage screen reports Claude Code and Codex. Reset detection and @@ -241,8 +236,6 @@ WebSocket upgrade requires the short-lived ticket created by its HTTP endpoint. - Named sessions: list/create, set active, and delete. - Files/workdir: current workdir, absolute directory browse, upload, and download. -- BTW: chat, history, and clear. Cancellation uses the normal chat cancellation - endpoint and side-scope metadata. - Swarms: list/create, update members, delete, history, clear, chat, and cancel. - Quota: usage, schedules, schedule replacement, and cancellation. - Push: browser subscription/config and FCM device registration. diff --git a/lib/core/backend/backend_client.dart b/lib/core/backend/backend_client.dart index 8a38fa2..0fcba55 100644 --- a/lib/core/backend/backend_client.dart +++ b/lib/core/backend/backend_client.dart @@ -931,25 +931,6 @@ class BackendClient { ); } - /// Send a /btw side question. Always streams; the backend forks the main - /// conversation's session so the answer has its memory without disturbing it. - Future 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) { diff --git a/lib/core/i18n/app_strings.dart b/lib/core/i18n/app_strings.dart index 6cb0aca..0111857 100644 --- a/lib/core/i18n/app_strings.dart +++ b/lib/core/i18n/app_strings.dart @@ -424,18 +424,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 diff --git a/lib/features/chat/bot_chat_screen.dart b/lib/features/chat/bot_chat_screen.dart index c0cec7a..f0bef0d 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'; @@ -270,28 +269,6 @@ class _BotChatScreenState extends State } } - Future _showBtw() async { - final CliAgent agent = widget.agentsController.activeAgent; - const Set btwAgents = {'claude', 'codex'}; - 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)), - ); - return; - } - await BtwDialog.show( - context, - backend: widget.chatController.backend, - agentKey: agent.key, - sessionId: sessionId, - language: widget.settingsController.language, - ); - } - Future _exportMarkdown() async { try { final ConversationExport export = await widget.chatController @@ -354,11 +331,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 +361,6 @@ class _BotChatScreenState extends State machinesController: widget.machinesController, chatController: widget.chatController, onSearch: _showHistorySearch, - onBtw: _showBtw, ), ListenableBuilder( listenable: Listenable.merge([ @@ -544,14 +515,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 +543,6 @@ class _DesktopChatHeader extends StatelessWidget { chatController: chatController, ), ), - _BtwButton( - agentsController: agentsController, - chatController: chatController, - onPressed: onBtw, - ), _SearchButton( chatController: chatController, onPressed: onSearch, @@ -647,57 +611,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'}; - 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}); 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/server/lib/agents.js b/server/lib/agents.js index a5272cd..075d1b5 100644 --- a/server/lib/agents.js +++ b/server/lib/agents.js @@ -295,11 +295,9 @@ const SESSION_POOLS = { // Delete a scope's conversation for good. Deleting a chat in the app means the // conversation is gone, so a pooled scope also loses its CLI-side transcript: // without that, the id would be forgotten while the transcript lingered on -// disk, resumable forever. Other agents just forget the id — their transcripts -// belong to their own CLIs. +// disk, resumable forever. async function purgeSession(sessionKey, options = {}) { - const agentKey = String(options.agentKey || '').replace(/^btw:/, ''); - const pool = SESSION_POOLS[agentKey]; + const pool = SESSION_POOLS[String(options.agentKey || '')]; if (!pool) return clearSession(sessionKey); const prior = getSession(sessionKey); const cleared = clearSession(sessionKey); @@ -326,23 +324,12 @@ function shutdownPools() { ); } -// 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, -}) { +// `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; a new conversation gets its id from // the CLI's first message and persists it once the turn succeeds. @@ -401,10 +388,8 @@ function runClaudeInvocation({ error, ) ) { - if (canRetry) { - if (sessionKey) clearSession(sessionKey); - return { __retry: true }; - } + if (sessionKey) clearSession(sessionKey); + return { __retry: true }; } if (isAuthError(error)) return { __authError: true }; return error || '(claude produced no output)'; @@ -420,10 +405,6 @@ function runClaudeInvocation({ // the wrong configuration and has to be replaced. optionsKey: JSON.stringify(sdkOptions), resumeId, - // 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. - forkSession, executablePath: claudeExecutablePath(), signal, onMessage, @@ -449,69 +430,14 @@ function runClaudeInvocation({ }, ); - return finishRun(run, { agentKey: 'claude', onEvent, retry }); -} - -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); - 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) { @@ -621,33 +547,6 @@ function runCodex(prompt, onEvent, sessionKey, signal, workdir, settings) { return finishRun(run, { agentKey: 'codex', onEvent }); } -// The /btw sidekick for codex: a read-only side question that inherits the main -// thread's memory without writing back to it. The branch is codex's own -// `thread/fork`; Relay previously had to copy rows and rollout files inside -// codex's private SQLite state to get the same result. -async 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 = await codexPool.driverCall( - 'fork', - mainThreadId, - workdir || getDefaultWorkdir(), - ); - setSession(btwSessionKey, { - id: childThreadId, - parentId: mainThreadId, - forkedAt: new Date().toISOString(), - }); - } - } - return runCodex(prompt, onEvent, btwSessionKey, signal, workdir, readOnlySettings); -} - // 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. @@ -860,8 +759,6 @@ module.exports = { getAgent, commandExists, runAgent, - runBtw, - runBtwAgent, getSession, clearSession, purgeSession, diff --git a/server/lib/claude-session-pool.js b/server/lib/claude-session-pool.js index c111bca..d4620ec 100644 --- a/server/lib/claude-session-pool.js +++ b/server/lib/claude-session-pool.js @@ -257,9 +257,6 @@ function createClaudeSessionPool(options = {}) { systemPrompt: { type: 'preset', preset: 'claude_code' }, includePartialMessages: true, ...(request.resumeId ? { resume: request.resumeId } : {}), - ...(request.resumeId && request.forkSession - ? { forkSession: true } - : {}), ...(request.executablePath ? { pathToClaudeCodeExecutable: request.executablePath } : {}), @@ -381,15 +378,9 @@ function createClaudeSessionPool(options = {}) { entry = null; } const warm = !!entry; - if (!entry) { - // A restart resumes the conversation it replaced rather than forking it - // a second time. - entry = await spawnEntry( - resumeId === request.resumeId - ? request - : { ...request, resumeId, forkSession: false }, - ); - } + // 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) { @@ -397,13 +388,7 @@ function createClaudeSessionPool(options = {}) { 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. - // The fork (if any) already happened when the session was first spawned, - // so resume into it rather than forking a second time. - const fresh = await spawnEntry({ - ...request, - resumeId, - forkSession: false, - }); + const fresh = await spawnEntry({ ...request, resumeId }); return runTurn(fresh, request); } } diff --git a/server/lib/codex-session-pool.js b/server/lib/codex-session-pool.js index d6bd2a8..6d41068 100644 --- a/server/lib/codex-session-pool.js +++ b/server/lib/codex-session-pool.js @@ -117,17 +117,6 @@ function createCodexDriver(rpc) { .then(() => true, () => false); }, - // Branch a thread into a new one that inherits its memory without writing - // back to it — how /btw asks a side question without disturbing the main - // task. Relay used to do this by copying rows and rollout files inside - // codex's private SQLite state; this is the supported operation for it. - async fork(threadId, cwd) { - const forked = await rpc.request('thread/fork', { threadId, cwd }); - const id = forked && forked.thread && forked.thread.id; - if (!id) throw new Error('codex returned no forked thread id'); - return id; - }, - handleMessage(msg) { const params = msg.params || {}; // Every Relay tier runs with approvalPolicy "never", so these should not diff --git a/server/lib/stdio-agent-pool.js b/server/lib/stdio-agent-pool.js index 5d509a0..e761b08 100644 --- a/server/lib/stdio-agent-pool.js +++ b/server/lib/stdio-agent-pool.js @@ -676,19 +676,6 @@ function createStdioAgentPool(options = {}) { if (conn) dropConnection(conn, null, true); } - // Call a protocol operation that is not a turn (codex's thread/fork). The - // connection is opened if needed and released again when nothing is using it. - async function driverCall(name, ...args) { - const c = await ensureConnection(); - const fn = c.driver[name]; - if (!fn) throw new Error(`${agentKey} does not support ${name}`); - try { - return await fn(...args); - } finally { - closeIdleConnection(); - } - } - function stats() { return { live: live.size, @@ -700,7 +687,7 @@ function createStdioAgentPool(options = {}) { }; } - return { send, forget, shutdown, stats, driverCall }; + return { send, forget, shutdown, stats }; } module.exports = { createStdioAgentPool }; diff --git a/server/routes/btw.js b/server/routes/btw.js deleted file mode 100644 index c4444e1..0000000 --- a/server/routes/btw.js +++ /dev/null @@ -1,200 +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']); - -function btwScopeAgent(agentKey) { - return `btw:${agentKey}`; -} - -module.exports = function createBtwRouter(ctx) { - const { - MAX_PROMPT_BYTES, - activeRequests, - agentTurnDependencies, - clearHistory, - purgeSession, - 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', async (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, workdir } = scope; - if (runningScopes.has(btwScopeKey)) { - return res - .status(409) - .json({ error: 'a side question is running', code: 'SESSION_BUSY' }); - } - // The side chat is its own forked session, so resetting it deletes that - // fork's transcript rather than leaving an unreachable one behind. - await purgeSession(btwScopeKey, { - agentKey: `btw:${agentKey}`, - workdir, - }); - clearHistory(btwScopeKey); - return res.json({ ok: true }); - }); - - return router; -}; diff --git a/server/routes/chat.js b/server/routes/chat.js index 86ae1bb..d852612 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -280,15 +280,6 @@ module.exports = function createChatRouter(ctx) { 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); - await purgeSession(btwScopeKey, { - agentKey: `btw:${agent.key}`, - workdir, - }); - clearHistory(btwScopeKey); touchChatSession(contextKey, chatSession.id); return res.json({ ok: true, @@ -307,13 +298,6 @@ module.exports = function createChatRouter(ctx) { 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); - await purgeSession(btwScopeKey, { - agentKey: `btw:${agent.key}`, - workdir, - }); - clearHistory(btwScopeKey); } } return res.json({ ok: true, workdir, cleared }); diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 331e70a..ebf0efa 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -129,14 +129,6 @@ module.exports = function createSessionsRouter(ctx) { const result = deleteChatSession(contextKey, sessionId); await purgeSession(scopeKey, { agentKey: agent.key, workdir }); clearHistory(scopeKey); - // The /btw side chat is a fork of this conversation and has a transcript of - // its own, so deleting the chat has to take it down too. - const btwScopeKey = scopeKeyFor(`btw:${agent.key}`, workdir, sessionId); - await purgeSession(btwScopeKey, { - agentKey: `btw:${agent.key}`, - workdir, - }); - clearHistory(btwScopeKey); return res.json({ ok: true, agent: agentPayload(agent), diff --git a/server/server.js b/server/server.js index 5740ca9..f526c72 100644 --- a/server/server.js +++ b/server/server.js @@ -17,8 +17,6 @@ const { getAgent, listAgents, runAgent, - runBtw, - runBtwAgent, purgeSession, shutdownPools, } = require('./lib/agents'); @@ -105,7 +103,6 @@ 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'); @@ -162,7 +159,6 @@ function isStreamingApiPath(req) { case '/events': case '/chat': case '/group/chat': - case '/btw': case '/fs/download': case '/fs/upload': case '/agent-auth/login/start': @@ -871,8 +867,6 @@ const routeContext = { resolveUploadTarget, revokeTokenById, runAgentTurn, - runBtw, - runBtwAgent, runningScopes, safeDownloadName, scopeChains, @@ -901,7 +895,6 @@ 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)); diff --git a/server/test/btw.test.js b/server/test/btw.test.js deleted file mode 100644 index 5b3e119..0000000 --- a/server/test/btw.test.js +++ /dev/null @@ -1,225 +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: [], purged: [] }; - 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: () => ({}), - purgeSession: async (key, options) => { - cleared.sessions.push(key); - cleared.purged.push(options); - 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 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/codex-session-pool.test.js b/server/test/codex-session-pool.test.js index f94d65c..eaa055c 100644 --- a/server/test/codex-session-pool.test.js +++ b/server/test/codex-session-pool.test.js @@ -223,15 +223,6 @@ test('approval requests are answered with codex vocabulary', async () => { await done(); }); -test('fork branches a thread through the protocol', async () => { - const { pool, state, done } = makePool(); - const main = await send(pool, 'a', 'one'); - const forked = await pool.driverCall('fork', main.sessionId, '/w'); - assert.notEqual(forked, main.sessionId); - assert.ok(state().includes(`fork ${main.sessionId} -> ${forked}`)); - 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'); diff --git a/server/test/fixtures/fake-codex-agent.js b/server/test/fixtures/fake-codex-agent.js index cbc63c5..af7289f 100644 --- a/server/test/fixtures/fake-codex-agent.js +++ b/server/test/fixtures/fake-codex-agent.js @@ -167,14 +167,6 @@ function handle(msg) { reply({ thread: { id: params.threadId } }); return; } - case 'thread/fork': { - counter += 1; - const id = `fork-${counter}`; - threads.set(id, { cwd: params.cwd }); - record(`fork ${params.threadId} -> ${id}`); - reply({ thread: { id } }); - return; - } case 'turn/start': handleTurnStart(msg); return; From bb67356a009eb7f56982551708e93240b261bd3d Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 19:04:54 -0400 Subject: [PATCH 07/12] feat: credential expiry, faster option/quota opens, swarm agent mentions Replace the in-app OAuth login bridge with a credential countdown. Relay never logs a CLI in now: `/api/agents` reports `credentialExpiresAt` read from the timestamps Claude Code and Codex already store beside their tokens, and Manage credentials shows the days left or the days since expiry instead of a Log in button. Drops `/api/agent-auth/*`, the backend PTY that drove `script -qfec`, and the login dialog, so the backend no longer starts a process on behalf of a client. Take the subprocesses off the option hot path. Every `/api/agent-options`, `/api/agent-settings`, and agent turn re-located the CLI with a synchronous `command -v` and re-read models-extra.json, blocking the event loop (and every SSE stream) for ~6 ms, ~12 ms for a settings read. Discovery now re-checks the binary at most once a minute and remembers absent CLIs; models-extra.json is cached by mtime; a CLI update still busts both at once. describeAgent 6.1 ms -> 0.04 ms, getSettings ~12 ms -> 0.03 ms. ` --version` is cached the same way. Client side, the composer caches the per-agent catalog so the "+" panel renders at its final size instead of spinning then growing, and the quota screens paint the last report while refreshing behind it. Let swarm members summon each other. A round now runs in waves: an `@mention` in a member's reply hands that teammate the floor, and each wave re-snapshots the transcript so the summoned member sees what was just said. Member prompts list their teammates and the @name that reaches each, since a member that does not know summoning works will never use it. RELAY_SWARM_MAX_HOPS bounds the agent-driven waves after one human message (default 3, 0 keeps it human-only), a member cannot summon itself, and a failed or cancelled turn summons no one. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 23 +- CHANGELOG.md | 37 +++ README.md | 13 +- README.zh-CN.md | 8 +- SECURITY.md | 12 +- docs/handbook.md | 33 +- lib/core/backend/backend_client.dart | 34 -- lib/core/i18n/app_strings.dart | 38 +-- lib/core/models/cli_agent.dart | 57 +++- lib/features/chat/agent_controls.dart | 80 ++++- lib/features/chat/bot_chat_controller.dart | 18 +- .../cli_agents/agent_status_lights.dart | 20 ++ .../machines/agent_login_flow_controller.dart | 142 -------- .../machines/machine_credentials_screen.dart | 241 +------------- .../quota/quota_scheduler_screen.dart | 3 + lib/features/quota/quota_usage_screen.dart | 145 ++++---- server/lib/agent-login.js | 310 ------------------ server/lib/agent-options.js | 33 +- server/lib/agent-settings.js | 12 +- server/lib/agent-status.js | 63 +++- server/lib/group-turn.js | 24 +- server/lib/model-discovery.js | 56 +++- server/routes/agent-auth.js | 86 ----- server/routes/group.js | 146 ++++++--- server/routes/meta.js | 20 +- server/server.js | 3 - server/test/agent-auth-route.test.js | 102 ------ server/test/agent-login.test.js | 149 --------- server/test/agent-status.test.js | 52 +++ server/test/group-route.test.js | 94 +++++- server/test/group-turn.test.js | 22 ++ server/test/meta-agents.test.js | 18 +- test/agent_controls_test.dart | 44 ++- test/cli_agent_test.dart | 137 ++++---- test/machine_credentials_screen_test.dart | 65 ++++ 35 files changed, 984 insertions(+), 1356 deletions(-) delete mode 100644 lib/features/machines/agent_login_flow_controller.dart delete mode 100644 server/lib/agent-login.js delete mode 100644 server/routes/agent-auth.js delete mode 100644 server/test/agent-auth-route.test.js delete mode 100644 server/test/agent-login.test.js diff --git a/AGENTS.md b/AGENTS.md index d469650..4ae22c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - `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, Swarms, - agent login, sessions, quota, and the SSH terminal ticket. + 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. Each OS has @@ -116,11 +116,20 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - 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. +- `describeAgent` and `getSettings` run on every option-picker open 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. -- The in-app OAuth bridge uses the backend host's `script -qfec` PTY utility. - Keep the process output redacted and never return credential values. +- Every credential is created on the backend host by the CLI itself. Relay does + not log an agent in. It reads the credential files in `server/lib/agent-status.js` + for auth state and, for Claude and Codex, a `credentialExpiresAt` timestamp so + the app can count down to the next login. Read timestamps only; a token value + must never reach the API or the app. ### Backend modules and persistence @@ -155,8 +164,12 @@ 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. - The SSH terminal exchanges the bearer credential for a short-lived, diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c3559..4574871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,29 @@ `/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. Set `ENABLE_CLAUDE_KEEPALIVE=false` to opt out. @@ -31,6 +51,23 @@ ### Changed +- 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 diff --git a/README.md b/README.md index b2abc70..db1aa35 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ flowchart LR agent starts in the background is still running on the next turn. - **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 four - agents. Relay can bridge Claude and Codex OAuth on compatible backend hosts; - OpenCode and Hermes credentials stay host-managed. +- **Agent status and credential expiry.** See installed/authenticated state for + all four agents, plus how many days are left on the Claude Code and Codex + OAuth credentials before you have to log in again on the backend host. All + four agents' 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. @@ -44,8 +45,10 @@ flowchart LR 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. + `@mentions`. Multiple members run in parallel from one transcript snapshot, and + members can hand the floor to each other by `@mentioning` a teammate in their + own reply — bounded so a pair cannot loop forever. Swarms can be saved and + imported as JSON templates. - **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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a052fd..c5d1281 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,14 +32,16 @@ flowchart LR 回合还在跑。 - **命名会话。** 每个工作目录与 agent 最多有 8 个持久会话,聊天历史和运行状态可在 多设备间同步。 -- **Agent 状态与登录。** 查看四种 agent 的安装和认证状态。兼容的后端可为 Claude、 - Codex 中转 OAuth;OpenCode 与 Hermes 的密钥仍由后端主机管理。 +- **Agent 状态与凭据有效期。** 查看四种 agent 的安装和认证状态,并显示 Claude Code + 与 Codex 的 OAuth 凭据还有几天到期、过期了几天,以便及时到后端主机上重新登录。 + 四种 agent 的凭据都由后端主机管理。 - **按 agent 配置。** 在输入区选择模型、思考深度和权限。Claude Code 与 Codex 还会 显示默认关闭的快速模式;快速响应可能消耗更多额度或产生更高费用。 - **Codex 动态目录。** 从已安装 Codex CLI 的结构化元数据读取模型与每个模型支持的 思考档位,并提供安全的回退目录。 - **蜂群。** 多个 agent 共享一份记录;每位成员可设置工作树、模型、思考深度、权限、 - 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行。 + 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行; + 成员也可以在自己的回复里 `@` 队友把发言权交出去,并有上限防止两人无限互相召唤。 蜂群还可保存和导入 JSON 模板。 - **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 - **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上唯一且可恢复的终端;终端 diff --git a/SECURITY.md b/SECURITY.md index c5932d5..15cc94e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,17 +57,17 @@ 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 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 command or provider key. Relay reads the +credential files only to report whether an agent is authenticated and, for +Claude Code and Codex, when the stored OAuth credential expires. Neither the +API nor the app ever receives a token value. ## SSH terminal diff --git a/docs/handbook.md b/docs/handbook.md index 4921693..663f87f 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -28,8 +28,7 @@ the backend OS user. A stable deployment should have all of the following: ### Reverse proxy requirements - Forward normal HTTP requests and long-lived SSE responses. Disable buffering - for `/api/events`, `/api/chat`, `/api/group/chat`, and - `/api/agent-auth/login/start`. + for `/api/events`, `/api/chat`, and `/api/group/chat`. - Forward `Upgrade`/`Connection` headers for the WebSocket endpoint `/api/terminal/connect`. Do not log its one-time `ticket` query value. - Keep proxy timeouts above `AGENT_TIMEOUT_MS` (60 minutes by default). @@ -82,12 +81,17 @@ subject to browser-origin storage security, so use a private profile on a trusted device. Relay reports separate installed, authenticated, and usable state for all four -known agents. Claude Code and Codex are OAuth-gated. Their in-app login bridge -starts the CLI in a PTY, streams the authorization URL, and accepts a device -code. The bridge currently depends on GNU-compatible -`script -qfec` (normally Linux); on unsupported hosts, log in directly in a -terminal. OpenCode and Hermes credentials remain host-managed; once installed, -Relay allows their runner to start and lets the CLI report provider errors. +known agents. Every agent's credential is created on the backend host: Claude +Code and Codex with their own `claude auth login` / `codex login`, OpenCode and +Hermes with a provider key. Relay never logs a CLI in remotely. + +For the two OAuth agents it also reads the expiry stored beside those +credentials — `claudeAiOauth.expiresAt` in `~/.claude/.credentials.json`, and +the `exp` claim of the `id_token` in `~/.codex/auth.json` — and reports it as +`credentialExpiresAt` (epoch ms) on `/api/agents`. The app turns that into the +days left, or the days since expiry, on the **Manage credentials** screen. Only +the timestamp is read; token values never leave the backend. Agents whose +credential carries no expiry report `null` and show no countdown. ## SSH terminal @@ -192,6 +196,15 @@ When a human message mentions multiple members, Relay snapshots the transcript once, builds a speaker-labelled delta for each member, and runs those members in parallel. Each member still serializes against its own private Swarm session. +Members can summon each other. A member's prompt lists the other members and the +`@name` that reaches each, and any of those names in its reply hands them the +floor: the round continues with another wave, snapshotted after the previous +replies so the newly summoned members see them. `RELAY_SWARM_MAX_HOPS` bounds how +many agent-driven waves follow the human's message (default 3; set 0 to keep +summoning human-only), because two members that keep naming each other would +otherwise run — and bill — without end. A member cannot summon itself, and a +turn that failed or was cancelled summons no one. + At creation, a Swarm selects its work tree and per-member model, effort, permission, nickname, and prompt/persona. The work tree cannot be changed later. Swarms can be cleared, updated, deleted, or saved as reusable JSON templates. @@ -229,9 +242,6 @@ WebSocket upgrade requires the short-lived ticket created by its HTTP endpoint. - Metadata/auth: health, agents, agent options/settings/version/update, auth status, diagnostics, device tokens, and shared events. -- Agent OAuth: `GET /api/agent-auth/login/start`, - `POST /api/agent-auth/login/code`, and - `GET /api/agent-auth/login/status`. - Chat: chat, cancellation, history, history search/export, and clear session. - Named sessions: list/create, set active, and delete. - Files/workdir: current workdir, absolute directory browse, upload, and @@ -309,6 +319,7 @@ groups are: idle/buffer limits; - agent sessions: `RELAY_CLAUDE_IDLE_MS`, `RELAY_CLAUDE_MAX_LIVE`, `RELAY_CLAUDE_BIN`, `RELAY_AGENT_IDLE_MS`, `RELAY_AGENT_MAX_SESSIONS`; +- Swarms: `RELAY_SWARM_MAX_HOPS`; - security/files: `CORS_ALLOW_ORIGIN`, `RELAY_FS_ROOTS`, upload/download caps; - usage: quota watch, poll interval, HTTP/probe timeouts and backoff; - offline push: VAPID keys and `FCM_SERVICE_ACCOUNT_FILE`. diff --git a/lib/core/backend/backend_client.dart b/lib/core/backend/backend_client.dart index 0fcba55..c0203ab 100644 --- a/lib/core/backend/backend_client.dart +++ b/lib/core/backend/backend_client.dart @@ -1382,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/i18n/app_strings.dart b/lib/core/i18n/app_strings.dart index 0111857..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,33 +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 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'; diff --git a/lib/core/models/cli_agent.dart b/lib/core/models/cli_agent.dart index ac441ce..706489b 100644 --- a/lib/core/models/cli_agent.dart +++ b/lib/core/models/cli_agent.dart @@ -7,6 +7,7 @@ class CliAgent { this.authed = true, bool? usable, String? authKind, + this.credentialExpiresAt, }) : usable = usable ?? (installed && (authed || key == 'opencode' || key == 'hermes')), authKind = authKind ?? 'unknown'; @@ -15,6 +16,7 @@ class CliAgent { 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', @@ -24,6 +26,9 @@ class CliAgent { 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, ); } @@ -35,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() { @@ -46,6 +57,7 @@ class CliAgent { 'authed': authed, 'usable': usable, 'authKind': authKind, + 'credentialExpiresAt': credentialExpiresAt?.millisecondsSinceEpoch, }; } @@ -58,12 +70,51 @@ 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) { 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 ca660cc..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 { @@ -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,7 +529,19 @@ 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, diff --git a/lib/features/cli_agents/agent_status_lights.dart b/lib/features/cli_agents/agent_status_lights.dart index 19c9b9c..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))), 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 075cc8d..0000000 --- a/lib/features/machines/agent_login_flow_controller.dart +++ /dev/null @@ -1,142 +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; - - AgentLoginPhase get phase => _phase; - String? get sessionId => _sessionId; - String? get url => _url; - String get output => _output; - String? get error => _error; - - bool get canSubmitCode => - _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; - _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 { - 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; - } - 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/machine_credentials_screen.dart b/lib/features/machines/machine_credentials_screen.dart index dc8c88a..0b669af 100644 --- a/lib/features/machines/machine_credentials_screen.dart +++ b/lib/features/machines/machine_credentials_screen.dart @@ -21,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'; @@ -149,7 +148,6 @@ class _MachineCredentialsScreenState extends State { const SizedBox(height: 18), _AgentCredentialStatusSection( agentsController: widget.agentsController!, - onLogin: _startAgentLogin, onRefresh: _refreshAgents, ), ], @@ -457,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, @@ -532,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 @@ -586,7 +569,6 @@ class _AgentCredentialStatusSection extends StatelessWidget { _AgentCredentialStatusTile( agent: agentsController.agents[index], showDivider: index > 0, - onLogin: onLogin, ), ], ), @@ -602,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) { @@ -615,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: [ @@ -628,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), ], ), ), @@ -655,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) { @@ -672,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, @@ -690,195 +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; - 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(strings.agentLoginOpenUrl), - 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), - ), - ), - ], - 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) - 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 => strings.agentLoginOpenUrl, - 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/server/lib/agent-login.js b/server/lib/agent-login.js deleted file mode 100644 index e28e755..0000000 --- a/server/lib/agent-login.js +++ /dev/null @@ -1,310 +0,0 @@ -'use strict'; - -const { spawn } = require('child_process'); -const { randomUUID } = require('crypto'); - -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 AUTH_HOSTS = { - claude: ['anthropic.com', 'claude.ai'], - codex: ['openai.com', 'chatgpt.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'], -}; - -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 sessionTtlMs = options.sessionTtlMs || SESSION_TTL_MS; - const maxRunningMs = options.maxRunningMs || SESSION_MAX_RUNNING_MS; - - function sessionPayload(session) { - return { - sessionId: session.id, - agent: session.agent, - }; - } - - 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; - 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 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, - createdAt: nowFn(), - finishedAt: null, - listeners: new Set(), - child: null, - }; - sessions.set(id, 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 (code === 0) { - finish(session, 'done'); - } else { - finish( - session, - 'error', - signal - ? `Login process ended with signal ${signal}.` - : `Login process exited with code ${code}.`, - ); - } - }); - 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.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, - }; - } - - return { start, subscribe, submitCode, status, cleanup }; -} - -module.exports = { - LOGIN_COMMANDS, - createAgentLoginManager, - selectLoginUrl, - scriptCommand, -}; diff --git a/server/lib/agent-options.js b/server/lib/agent-options.js index 87abe04..ffae77b 100644 --- a/server/lib/agent-options.js +++ b/server/lib/agent-options.js @@ -247,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)); 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 5e737d8..cb601dd 100644 --- a/server/lib/agent-status.js +++ b/server/lib/agent-status.js @@ -36,23 +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)); + 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 = '') { @@ -94,18 +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); + 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 }; } } @@ -113,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/group-turn.js b/server/lib/group-turn.js index ebd85e4..9a8df60 100644 --- a/server/lib/group-turn.js +++ b/server/lib/group-turn.js @@ -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 argv cap. 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]'; diff --git a/server/lib/model-discovery.js b/server/lib/model-discovery.js index bfe88c8..31f3af3 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 { @@ -341,22 +348,38 @@ const STRATEGIES = { }, }; +// 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); @@ -364,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/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/group.js b/server/routes/group.js index ed028eb..3a73db4 100644 --- a/server/routes/group.js +++ b/server/routes/group.js @@ -27,6 +27,18 @@ const { normalizeSettings } = require('../lib/agent-options'); 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() @@ -115,6 +127,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); @@ -412,44 +425,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 @@ -480,7 +536,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, }, @@ -488,7 +544,7 @@ module.exports = function createGroupRouter(ctx) { prompt: groupPrompt, recordHistory: true, recordUserMessage: false, - requestId: `${requestId}.${index}.${memberKey}`, + requestId: turnRequestId, responder, runState, scopeKey, @@ -496,15 +552,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 bc23761..4310434 100644 --- a/server/routes/meta.js +++ b/server/routes/meta.js @@ -65,6 +65,12 @@ module.exports = function createMetaRouter(ctx) { installed, authed, authKind: status.authKind || 'unknown', + // 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. @@ -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, diff --git a/server/server.js b/server/server.js index f526c72..3d75a9b 100644 --- a/server/server.js +++ b/server/server.js @@ -104,7 +104,6 @@ const createQuotaRouter = require('./routes/quota'); const createPushRouter = require('./routes/push'); const createMetaRouter = require('./routes/meta'); const createGroupRouter = require('./routes/group'); -const createAgentAuthRouter = require('./routes/agent-auth'); const createTerminalRouter = require('./routes/terminal'); const PORT = parseInt(process.env.PORT || '8787', 10); @@ -161,7 +160,6 @@ function isStreamingApiPath(req) { case '/group/chat': case '/fs/download': case '/fs/upload': - case '/agent-auth/login/start': return true; default: return false; @@ -896,7 +894,6 @@ app.use(createPushRouter(routeContext)); app.use(createFsRouter(routeContext)); app.use(createChatRouter(routeContext)); app.use(createGroupRouter(routeContext)); -app.use(createAgentAuthRouter(routeContext)); app.use(createSessionsRouter(routeContext)); app.use(createQuotaRouter(routeContext)); app.use(createTerminalRouter(routeContext)); 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 f6af3d4..0000000 --- a/server/test/agent-login.test.js +++ /dev/null @@ -1,149 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const { EventEmitter } = require('node:events'); -const { test } = require('node:test'); - -const { - 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('claude', [ - 'https://example.test/help', - 'https://claude.ai/oauth/authorize?client_id=claude.', - ]).url, - 'https://claude.ai/oauth/authorize?client_id=claude', - ); -}); - -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('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-status.test.js b/server/test/agent-status.test.js index 3bb07ac..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'), { @@ -70,24 +78,68 @@ test('detects installed CLI agents and credential files without exposing values' installed: true, authed: true, authKind: 'oauth', + credentialExpiresAt: null, }); assert.deepEqual(result.codex, { 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'), { diff --git a/server/test/group-route.test.js b/server/test/group-route.test.js index b34bd52..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; }, }); @@ -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 4894336..1a65bd2 100644 --- a/server/test/group-turn.test.js +++ b/server/test/group-turn.test.js @@ -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/meta-agents.test.js b/server/test/meta-agents.test.js index 6462cb6..3cbb7be 100644 --- a/server/test/meta-agents.test.js +++ b/server/test/meta-agents.test.js @@ -15,8 +15,18 @@ before(async () => { createMetaRouter({ DEFAULT_AGENT: 'claude', getAgentStatuses: () => ({ - claude: { installed: true, authed: true, authKind: 'oauth' }, - codex: { installed: true, 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, @@ -66,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', @@ -75,9 +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.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/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/cli_agent_test.dart b/test/cli_agent_test.dart index a33d4e8..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,80 +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'}, - ), + expect( + agent.credentialExpiresAt, + DateTime.fromMillisecondsSinceEpoch(1893456000000), ); - await pumpEventQueue(); + expect(legacy.credentialExpiresAt, isNull); + }); - expect(controller.phase, AgentLoginPhase.waitingForUrl); - expect(controller.sessionId, 's1'); + 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); + }); - events.add( - const BackendEvent( - type: 'login_url', - data: { - 'sessionId': 's1', - 'agent': 'codex', - 'url': 'https://example.test/login', - }, + 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', ); - 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( + agentCredentialExpiryMessage( + strings, + claudeExpiring(const Duration(days: -2)), + now: now, ), + 'Expired 2 days ago. Log in again on the backend host.', ); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.done); - }); - - test('agent login flow surfaces stream errors', () async { - final AgentLoginFlowController controller = AgentLoginFlowController( - startLogin: (_) => Stream.error( - BackendException('could not start'), + expect( + agentCredentialExpiryMessage( + strings, + const CliAgent( + key: 'opencode', + label: 'OpenCode', + description: 'OpenCode CLI', + authKind: 'apiKeyOptional', + ), + now: now, ), - submitCode: (_, __) async {}, + isNull, ); - addTearDown(controller.dispose); - - await controller.start('claude'); - await pumpEventQueue(); - - expect(controller.phase, AgentLoginPhase.error); - expect(controller.error, 'could not start'); }); } 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 { From 7bd2cc0aa9703604f9b5eaef737ccc5995878231 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Sun, 16 Aug 2026 19:32:15 -0400 Subject: [PATCH 08/12] fix(chat): jump to the matched message when opening a search hit Picking a "Search chats" result only called selectSession, which switched the conversation but left the view on the newest message with nothing marked. The messageId the backend already returns was dropped, and the agents controller kept pointing at the old agent, so the next context sync could load that agent's conversation back over the jump. The hit now moves the active agent as well as the chat controller, then scrolls the (reversed, lazily built) list to the matched message by walking toward it on the scroll position's own average-extent estimate until the row mounts and ensureVisible can centre it. The row flashes a tint and the search term is marked inside the bubble for a couple of seconds; the markdown path marks it via a custom inline syntax so the surrounding formatting still renders. Co-Authored-By: Claude Opus 5 --- lib/features/chat/bot_chat_screen.dart | 241 +++++++++++++++++---- lib/features/chat/chat_content.dart | 117 +++++++++- pubspec.lock | 2 +- pubspec.yaml | 3 + test/chat_search_jump_test.dart | 288 +++++++++++++++++++++++++ test/message_highlight_test.dart | 113 ++++++++++ 6 files changed, 723 insertions(+), 41 deletions(-) create mode 100644 test/chat_search_jump_test.dart create mode 100644 test/message_highlight_test.dart diff --git a/lib/features/chat/bot_chat_screen.dart b/lib/features/chat/bot_chat_screen.dart index f0bef0d..5dda9c8 100644 --- a/lib/features/chat/bot_chat_screen.dart +++ b/lib/features/chat/bot_chat_screen.dart @@ -56,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(); @@ -88,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(); @@ -214,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. @@ -246,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( @@ -266,6 +291,69 @@ class _BotChatScreenState extends State backgroundColor: Theme.of(context).colorScheme.error, ), ); + return; + } + if (!mounted) return; + await _revealSearchMatch(result.messageId, picked.query); + } + + /// 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, + ); + if ((target - pos.pixels).abs() < 1) return; + _scroll.jumpTo(target); } } @@ -436,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, ); }, ), @@ -1129,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() { @@ -1140,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, @@ -1184,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), + ), ), ), ], @@ -1613,6 +1718,60 @@ List _persistedSteps(ChatMessage message) { .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, @@ -1627,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; @@ -1720,6 +1885,7 @@ class _MessageBubble extends StatelessWidget { segments: segments, color: textColor, formatInlineEmphasis: !streaming, + highlightQuery: highlightQuery, ) else if (message.content.isNotEmpty) if (planSplit != null) @@ -1734,6 +1900,7 @@ class _MessageBubble extends StatelessWidget { text: planSplit.plan, color: textColor, formatInlineEmphasis: true, + highlightQuery: highlightQuery, ), ), const SizedBox(height: 8), @@ -1741,6 +1908,7 @@ class _MessageBubble extends StatelessWidget { text: planSplit.body, color: textColor, formatInlineEmphasis: true, + highlightQuery: highlightQuery, ), ], ) @@ -1749,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/chat_content.dart b/lib/features/chat/chat_content.dart index c52467d..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), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 3309b9e..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 diff --git a/pubspec.yaml b/pubspec.yaml index 6a1b0a0..923ea97 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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/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/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); + }); + }); +} From f6510302dbd5bdb06975add4bd23a70aa4583a5d Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Tue, 18 Aug 2026 21:05:46 -0400 Subject: [PATCH 09/12] docs: align guidance with current implementation --- AGENTS.md | 36 ++++---- CHANGELOG.md | 25 ++++-- README.md | 39 ++++++--- README.zh-CN.md | 30 +++++-- SECURITY.md | 36 +++++--- backends/README.md | 21 +++-- backends/README.zh-CN.md | 18 ++-- docs/handbook.md | 85 ++++++++++++++----- .../machines/deploy_backend_screen.dart | 14 +-- .../settings/getting_started_screen.dart | 5 +- server/.env.example | 40 +++++---- server/lib/agent-options.js | 6 +- server/lib/agents.js | 11 ++- server/lib/claude-session-pool.js | 4 +- server/lib/group-turn.js | 16 ++-- server/lib/groups.js | 8 +- server/lib/model-discovery.js | 2 +- server/lib/quota-keepalive.js | 3 +- server/lib/stdio-agent-pool.js | 14 ++- server/lib/usage.js | 9 +- server/routes/chat.js | 3 +- server/routes/group.js | 11 ++- server/server.js | 19 ++--- 23 files changed, 287 insertions(+), 168 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ae22c1..b8cc9ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,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 @@ -72,8 +72,9 @@ 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 @@ -105,8 +106,8 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - `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 deletes the CLI-side transcript so - the conversation is really gone. Use `clearSession` only for the internal + `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. @@ -116,7 +117,7 @@ clients and bundles CanvasKit locally instead of depending on gstatic. - 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. -- `describeAgent` and `getSettings` run on every option-picker open and every +- `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 @@ -126,10 +127,10 @@ clients and bundles CanvasKit locally instead of depending on gstatic. 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. It reads the credential files in `server/lib/agent-status.js` - for auth state and, for Claude and Codex, a `credentialExpiresAt` timestamp so - the app can count down to the next login. Read timestamps only; a token value - must never reach the API or the app. + 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 @@ -143,8 +144,8 @@ clients and bundles CanvasKit locally instead of depending on gstatic. 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 @@ -183,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 @@ -196,7 +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 4574871..fe5032c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.5 - 2026-07-27 +## 0.1.5 - Unreleased ### Removed @@ -11,7 +11,7 @@ `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 - uses the device-code flow. + 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 @@ -41,7 +41,8 @@ - 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. Set `ENABLE_CLAUDE_KEEPALIVE=false` to opt out. + 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 @@ -88,9 +89,10 @@ 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 — and changing the model, reasoning effort or - permission tier applies to the live session without restarting anything. - No agent runs one process per turn any more. + 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 @@ -108,9 +110,9 @@ 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 deletes the CLI-side transcript as - well, so a deleted conversation can no longer be resumed and no longer lingers - on disk. This covers all four agents. +- 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` @@ -118,6 +120,11 @@ - 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/README.md b/README.md index db1aa35..3d9c0f8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ 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. +Claude Code and Codex are the primary integrations. OpenCode and Hermes are +available as experimental host-managed integrations. + 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. @@ -30,21 +33,27 @@ flowchart LR updates, and continue long work while switching between conversations. - **Persistent agent sessions.** Every agent keeps a live CLI session between messages, the way a terminal does, so follow-up turns skip the cold start, - cancelling a turn interrupts it instead of ending the conversation, and work an - agent starts in the background is still running on the next turn. + and cancelling a turn interrupts it instead of ending the conversation. + Background work survives normally for Claude, OpenCode, and Hermes; Codex + commands must detach into their own session because of its sandbox. - **Named conversations.** Each workdir and agent supports up to eight persistent sessions with shared cross-device history and running-state indicators. +- **History tools.** Search the current workdir across agents and sessions, jump + to the matching message, and export the current conversation as Markdown. - **Agent status and credential expiry.** See installed/authenticated state for all four agents, plus how many days are left on the Claude Code and Codex OAuth credentials before you have to log in again on the backend host. All four agents' credentials stay host-managed. +- **Device credential management.** A backend status panel lists device tokens, + last-use metadata, and lets you revoke a token before deleting its record. - **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 +- **Swarms.** Put several agents in one transcript, choose their shared work + tree, give each member a model, effort, permission, nickname, and persona, + then summon members with `@mentions`. Multiple members run in parallel from one transcript snapshot, and members can hand the floor to each other by `@mentioning` a teammate in their own reply — bounded so a pair cannot loop forever. Swarms can be saved and @@ -52,13 +61,15 @@ flowchart LR - **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. + interactive shell per device token on the current backend machine. It is not + a connection to the host SSH daemon: the PTY runs directly as the backend OS + user. Web bundles a terminal monospace font so Chromium keeps normal + horizontal character spacing. - **Quota workflows.** View Claude and Codex usage. Both can queue one prompt for the next detected five-hour reset. The backend keeps Claude's five-hour window cycling with a minimal request so its reset time is - never unknown. + not lost after an idle window. Codex usage probing and the optional Claude + keepalive both make small provider requests and can consume quota. - **Notifications.** Live local/browser alerts plus optional Web Push and Android FCM for configured deployments. @@ -68,7 +79,9 @@ flowchart LR You need a Linux, macOS, or Windows machine with Node.js 18+ and at least one supported CLI installed. Claude and Codex must be logged in; OpenCode and -Hermes provider setup is managed on that host. +Hermes provider setup is managed on that host. Unix hosts also need `zip` for +directory downloads; Linux needs PM2 plus the native build tools listed in the +[backend guide](backends/README.md#requirements). From the repository root, run the setup for the backend OS: @@ -99,8 +112,9 @@ details. 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. +JSON, then enter the passphrase chosen during generation. Camera scanning is +mobile-only; every platform supports image/file or pasted-JSON import. Generate +a separate credential for each device. The app's first connection screen also contains a **Deploy backend** walkthrough. @@ -115,6 +129,8 @@ Swarm. The active workdir is stored per client and sent with every API request. - 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. +- Quota reporting may read and refresh the Claude/Codex OAuth files on the host, + but token values are never returned by Relay's API. - 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. @@ -132,6 +148,7 @@ 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 ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index c5d1281..9351eb6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,8 @@ Relay 让 Claude Code、Codex、OpenCode 和 Hermes 继续运行在已经准备 shell 与登录态的机器上,再通过同一个 Flutter app 从手机、Web 或桌面重新连接这些本地 CLI 智能体,不需要把项目搬到托管服务。 +Claude Code 与 Codex 是主要集成;OpenCode 与 Hermes 目前作为由主机管理的实验性集成提供。 + Relay 没有云端账号,也没有内置的默认后端。你自己运行 Node.js 后端、生成加密凭证, 再把凭证导入信任的客户端。 @@ -28,28 +30,33 @@ flowchart LR - **实时智能体聊天。** 流式显示回复、取消任务、保留多段 agent 更新;切换会话后长任务 仍可继续运行。 - **常驻 agent 会话。** 每个 agent 在消息之间保持一个活的 CLI 会话,就像终端里那样: - 后续回合省掉冷启动,取消只是打断本回合而不会结束对话,agent 在后台起的活儿到下一 - 回合还在跑。 + 后续回合省掉冷启动,取消只是打断本回合而不会结束对话。Claude、OpenCode 与 Hermes + 的后台任务通常可以继续;Codex 因 sandbox 限制,需要让命令脱离到独立 session。 - **命名会话。** 每个工作目录与 agent 最多有 8 个持久会话,聊天历史和运行状态可在 多设备间同步。 +- **历史工具。** 可在当前工作目录中跨 agent、跨会话搜索,直接跳到命中的消息,并把 + 当前会话导出为 Markdown。 - **Agent 状态与凭据有效期。** 查看四种 agent 的安装和认证状态,并显示 Claude Code 与 Codex 的 OAuth 凭据还有几天到期、过期了几天,以便及时到后端主机上重新登录。 四种 agent 的凭据都由后端主机管理。 +- **设备凭证管理。** 后端状态面板会列出设备 token 与最近使用信息,并支持先吊销、再 + 删除 token 记录。 - **按 agent 配置。** 在输入区选择模型、思考深度和权限。Claude Code 与 Codex 还会 显示默认关闭的快速模式;快速响应可能消耗更多额度或产生更高费用。 - **Codex 动态目录。** 从已安装 Codex CLI 的结构化元数据读取模型与每个模型支持的 思考档位,并提供安全的回退目录。 -- **蜂群。** 多个 agent 共享一份记录;每位成员可设置工作树、模型、思考深度、权限、 - 昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行; +- **蜂群。** 多个 agent 共享一份记录和选定的工作树;每位成员可设置模型、思考深度、 + 权限、昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行; 成员也可以在自己的回复里 `@` 队友把发言权交出去,并有上限防止两人无限互相召唤。 蜂群还可保存和导入 JSON 模板。 - **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 -- **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上唯一且可恢复的终端;终端 - 使用后端系统用户运行,并跟随 app 的“白天/黑夜”外观。Web 端内置等宽终端字体, - 避免 Chromium 中的字符横向间距过大。 +- **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上按设备 token 隔离、可恢复 + 的交互 shell。它并不连接主机的 SSH daemon,而是直接以后端系统用户运行 PTY。 + Web 端内置等宽终端字体,避免 Chromium 中的字符横向间距过大。 - **额度工作流。** 查看 Claude 与 Codex 额度;两者都可以预约在 下一个检测到的 5 小时额度重置后自动发送一条消息。后端会用一次极小请求让 Claude - 的 5 小时窗口持续滚动,重置时间不再显示为“未知”。 + 的 5 小时窗口持续滚动,避免空闲后丢失重置时间。Codex 额度探测与可选的 Claude + keepalive 都会向供应商发出小请求,可能消耗额度。 - **通知。** 在线时使用本地/浏览器通知;配置后还可使用 Web Push 和 Android FCM。 ## 快速开始 @@ -58,6 +65,8 @@ flowchart LR 准备一台安装了 Node.js 18+ 的 Linux、macOS 或 Windows 主机,并至少安装一个支持的 CLI。Claude 与 Codex 需要登录;OpenCode 与 Hermes 的 provider 配置在主机完成。 +Unix 主机下载文件夹时还需要 `zip`;Linux 还需要 PM2 和 +[后端说明](backends/README.zh-CN.md#前置要求)列出的本地编译工具。 在仓库根目录运行后端系统对应的命令: @@ -87,7 +96,7 @@ CLI。Claude 与 Codex 需要登录;OpenCode 与 Hermes 的 provider 配置在 安装完成后会打印一张加密二维码,并在 `server/credentials/` 下保存 `.relay.png` / `.relay.json`。通过相机、图片/文件或粘贴 JSON 导入,再输入生成时设置的密码。每台设备 -应单独生成一份凭证。 +应单独生成一份凭证。相机扫描只在移动端提供;所有平台都支持导入图片/文件或粘贴 JSON。 app 的首次连接页也内置了“部署后端”向导。 @@ -102,6 +111,8 @@ app 的首次连接页也内置了“部署后端”向导。 - SSH 终端用该 token 换取短时、一次性的 WebSocket 票据,长期 bearer token 不会进入 WebSocket 地址。 - 凭证导出使用 PBKDF2-HMAC-SHA256 与 AES-256-GCM 加密。 +- 额度查询可能读取并刷新主机上的 Claude/Codex OAuth 文件,但 Relay API 不会返回 + token 值。 - 文件 API 会拒绝一组明确的 Relay、SSH、Claude 与 Codex 敏感路径,并可用 `RELAY_FS_ROOTS` 进一步限制。 - 错误 token 尝试会被限速。 @@ -116,6 +127,7 @@ Relay 不是沙箱:CLI 与 SSH 终端进程都拥有后端系统用户的权 flutter pub get flutter analyze --no-pub flutter test --no-pub +npm --prefix server install npm --prefix server test ``` diff --git a/SECURITY.md b/SECURITY.md index 15cc94e..8e9ea8f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,10 +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 never written to disk. The generator prompts for it -interactively; `--passphrase` and `RELAY_CREDENTIAL_PASSPHRASE` exist for -unattended setup and should be avoided otherwise, because both leave the value -readable in shell history or the process environment. +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 @@ -40,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 @@ -64,10 +70,19 @@ Implemented controls include: - a startup warning when a routable public URL uses plaintext HTTP. Relay never logs a CLI agent in. Every agent's credential is created on the -backend host with that CLI's own login command or provider key. Relay reads the -credential files only to report whether an agent is authenticated and, for -Claude Code and Codex, when the stored OAuth credential expires. Neither the -API nor the app ever receives a token value. +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 @@ -117,7 +132,8 @@ 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 diff --git a/backends/README.md b/backends/README.md index a3d336d..e73d1b2 100644 --- a/backends/README.md +++ b/backends/README.md @@ -11,9 +11,11 @@ Cloudflare Tunnel startup to each operating system. - Node.js 18 or newer. - At least one supported CLI installed on the backend: Claude Code, Codex, OpenCode, or Hermes. -- The CLI must be authenticated on the host. Relay can bridge OAuth login for - Claude and Codex when the host provides the compatible `script` PTY utility; - OpenCode and Hermes keys remain host-managed. +- 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,7 +41,9 @@ 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 @@ -67,7 +71,8 @@ for tunnel modes, `relay-tunnel`. Logs are under `~/.pm2/logs/` as 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 @@ -99,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: @@ -110,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 c4851ff..8587d1b 100644 --- a/backends/README.zh-CN.md +++ b/backends/README.zh-CN.md @@ -9,8 +9,10 @@ Relay 在所有后端操作系统上使用同一个 Node.js 服务和同一套 H - Node.js 18 或更新版本。 - 后端至少安装一个支持的 CLI:Claude Code、Codex、OpenCode 或 Hermes。 -- CLI 需要在后端主机上完成认证。当主机提供兼容的 `script` PTY 工具时,Relay 可以 - 为 Claude 和 Codex 中转 OAuth 登录;OpenCode 和 Hermes 的密钥仍在主机管理。 +- 每个 CLI 都必须直接在后端主机上完成认证或 provider 配置。Relay 只报告状态, + 不执行 OAuth 登录,也不收集 provider 密钥。 +- Unix 主机下载文件夹时需要 `zip`。Linux 安装还需要 PM2、Python 3、`make` 和 + C++ 编译器,以编译终端的 PTY 依赖。 - 只有正式 Cloudflare Tunnel 或 Quick Tunnel 模式需要 `cloudflared`。 ## 安装 @@ -33,7 +35,8 @@ 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://新地址`。 ## 服务管理 @@ -59,7 +62,8 @@ Linux 安装需要 PM2(`npm install -g pm2`)。进程名为 `relay-server` `relay-tunnel`。日志位于 `~/.pm2/logs/`,文件名为 `relay-server-*.log` 和 `relay-tunnel-*.log`。`uninstall.sh` 只删除 PM2 进程,保留后端数据、令牌和凭证。 交互终端的 PTY 依赖会在 Linux 上本地编译,因此首次安装还需要 Python 3、 -`make` 和 C++ 编译器(Debian/Ubuntu 可安装 `build-essential`)。 +`make` 和 C++ 编译器(Debian/Ubuntu 可安装 `build-essential`)。如果客户端需要下载 +文件夹,还要安装 `zip`。 ### macOS @@ -89,6 +93,9 @@ LaunchAgent 位于 `~/Library/LaunchAgents`。日志在 `~/Library/Logs/Relay/` Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass ``` +三个平台的卸载脚本都只移除受管理的服务,配置、token、凭证、历史和日志会保留,需按需 +手动清理。 + ## 手动启动 开发或排障时可以绕过平台服务脚本: @@ -100,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/docs/handbook.md b/docs/handbook.md index 663f87f..fab8106 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -23,7 +23,8 @@ the backend OS user. A stable deployment should have all of the following: - Generate one credential per device. Revoke and delete old device tokens rather than sharing one token. - Keep `.env`, tokens, credential exports, CLI login state, push keys, history, - sessions, settings, groups, and quota state out of git and release archives. + sessions, settings, groups, quota state, and FCM service-account files out of + git and release archives. ### Reverse proxy requirements @@ -55,7 +56,8 @@ backend user for the actual production boundary. Directory downloads are zipped and rejected if the tree would contain a denied path. Native uploads/downloads stream; Web downloads use a browser Blob and may -hold the file in memory up to the configured cap. +hold the file in memory up to the configured cap. Unix directory downloads call +the host's `zip` command; Windows uses PowerShell `Compress-Archive`. ## Credentials and agent login @@ -64,7 +66,7 @@ envelope containing the backend URL, machine identity, and one revocable device token. It uses PBKDF2-HMAC-SHA256 (600,000 iterations) and AES-256-GCM. The passphrase is prompted for interactively and is not saved. Unattended setups can supply it with `--passphrase` or `RELAY_CREDENTIAL_PASSPHRASE`, at the cost of -exposing it to shell history or the process environment. +exposing it to shell history, the process environment, or a launcher/config file. Useful commands from `server/`: @@ -80,18 +82,29 @@ and pasted JSON. Native clients use platform secure storage. The Web client is subject to browser-origin storage security, so use a private profile on a trusted device. +Generate a different credential for each device. The backend status panel lists +their token ids, device metadata, and last-use time. Revoke a token before +deleting its record; revocation also closes the terminal owned by that token. +Generating another credential removes old export files but does not revoke +tokens that were already issued. + Relay reports separate installed, authenticated, and usable state for all four -known agents. Every agent's credential is created on the backend host: Claude -Code and Codex with their own `claude auth login` / `codex login`, OpenCode and -Hermes with a provider key. Relay never logs a CLI in remotely. +known agents. Claude Code and Codex are the primary integrations; OpenCode and +Hermes are experimental. Every agent's credential or provider configuration is +created on the backend host with that CLI's own flow. Relay never logs a CLI in +remotely. For the two OAuth agents it also reads the expiry stored beside those credentials — `claudeAiOauth.expiresAt` in `~/.claude/.credentials.json`, and the `exp` claim of the `id_token` in `~/.codex/auth.json` — and reports it as `credentialExpiresAt` (epoch ms) on `/api/agents`. The app turns that into the days left, or the days since expiry, on the **Manage credentials** screen. Only -the timestamp is read; token values never leave the backend. Agents whose -credential carries no expiry report `null` and show no countdown. +the status/expiry result is returned by this endpoint. Separately, +`server/lib/usage.js` reads the Claude/Codex OAuth tokens for quota reporting +and can refresh an expired access token atomically in the CLI's credential +file. Token values are sent only to the provider's OAuth/API endpoints and +never returned by Relay's API. Agents whose credential carries no expiry report +`null` and show no countdown. ## SSH terminal @@ -139,18 +152,20 @@ closed reloads into the same conversation on its next turn — the same behaviou as before, just slower for that one turn. Idle sessions are closed and there is a cap on how many stay live, because these -processes are large (roughly 300 MB per Claude process, 360 MB for an opencode -process plus about 130 MB per session, 90 MB for hermes). See `RELAY_CLAUDE_*` -and `RELAY_AGENT_*` in `server/.env.example`. Turns past the cap wait for a slot. -Hermes runs one turn at a time per process, so concurrent Hermes chats queue. +processes can be resource-intensive. See `RELAY_CLAUDE_*` and `RELAY_AGENT_*` +in `server/.env.example`. Defaults retain up to three Claude processes and four +live JSON-RPC sessions per agent, with a 15-minute idle timeout. Turns past a +cap wait for a slot. Work an agent starts in the background now outlives the turn that started it, except on Codex: its sandbox kills each command's process group as the command returns, so background work there survives only if it detaches into its own session (`setsid`). -Deleting or clearing a conversation deletes the CLI-side transcript too, so a -deleted conversation cannot be resumed and does not linger on disk. +Deleting or clearing a conversation removes Relay's history and stored resume +id, then asks the pooled integration to remove its CLI-side transcript. That +last step is best effort because the external CLI can reject or fail deletion; +inspect the host's CLI state if guaranteed erasure is required. ### Workdirs, conversations, and settings @@ -184,14 +199,37 @@ Codex `serviceTier` on every turn. Availability still depends on the selected model, CLI version, account, and provider. Swarm storage can retain the field, but the current Swarm form exposes only model, effort, and permission. +Claude settings are fixed for the life of its per-conversation SDK process, so +a change restarts that process and resumes the same conversation. OpenCode and +Hermes settings apply over ACP without restarting their shared process. Codex +applies model, effort, and service tier per turn; changing its sandbox reopens +the thread while resuming the same conversation, without respawning the shared +app-server process. + Codex model and reasoning choices come from the installed CLI's structured catalog and keep each model's advertised order/default. Updating Codex clears the discovery cache. Other agents use their supported live or fallback catalogs; local pins may be added in the gitignored `server/models-extra.json`. +### History, search, and export + +Relay keeps raw chat messages in `server/chat-history.json`, capped at the most +recent 200 messages in each conversation or Swarm scope. This file is backend +state, not an encrypted archive, and can contain prompts, agent output, and +sensitive project context. Protect it and any backups with the same care as the +backend account. + +The client can search the current workdir across named sessions and agents, or +limit the search to the current agent. The backend returns at most 50 matches; +the client can jump to and highlight a result. Markdown export covers the +current conversation. Search snippets and exported Markdown pass through +Relay's targeted token-pattern redaction, but that filter is not a general +secret scanner and does not alter the raw stored history. + ### Swarms A Swarm is one canonical transcript above several independent CLI sessions. +Each workspace can store up to 20 Swarms, with up to eight members in each. When a human message mentions multiple members, Relay snapshots the transcript once, builds a speaker-labelled delta for each member, and runs those members in parallel. Each member still serializes against its own private Swarm session. @@ -219,11 +257,14 @@ prompt per source and workspace for the next detected five-hour reset. Claude's five-hour window only exists while it runs: once it lapses the usage API reports no reset time, which the app can only show as unknown. The backend keeps -the window cycling by sending one minimal Claude Code request (cheapest model, -one output token) whenever the window is idle, then sleeping until just after the -new reset moment — the same effect Codex gets for free from its quota probe. Set -`ENABLE_CLAUDE_KEEPALIVE=false` to turn it off and accept the unknown state; -`CLAUDE_KEEPALIVE_MODEL` overrides the model used for the ping. +the window cycling by sending one minimal Claude Code request (one output token) +whenever the window is idle, then sleeping until just after the new reset +moment. It is enabled by default, is billed like an ordinary provider request, +and can consume quota. Set `ENABLE_CLAUDE_KEEPALIVE=false` to turn it off and +accept the unknown state; `CLAUDE_KEEPALIVE_MODEL` overrides the model used for +the ping. Codex quota discovery likewise sends a minimal Responses request to +obtain rate-limit headers and can consume quota. Both usage paths may refresh +the host's OAuth access token. Notification delivery has three layers: @@ -240,8 +281,8 @@ do not have a configured offline push channel in this repository. All HTTP `/api/*` endpoints require the imported bearer token. The terminal WebSocket upgrade requires the short-lived ticket created by its HTTP endpoint. -- Metadata/auth: health, agents, agent options/settings/version/update, - auth status, diagnostics, device tokens, and shared events. +- Metadata/auth: health, client auth status, agents and their auth state, agent + options/settings/version/update, diagnostics, device tokens, and shared events. - Chat: chat, cancellation, history, history search/export, and clear session. - Named sessions: list/create, set active, and delete. - Files/workdir: current workdir, absolute directory browse, upload, and @@ -268,7 +309,7 @@ npm start On Linux, `node-pty` compiles a native addon during `npm install`; install Python 3, `make`, and a C++ compiler first (for example `build-essential` on Debian/Ubuntu). macOS and Windows use the package's supported prebuilt binaries -when available. +when available. Unix hosts also need `zip` for directory downloads. To let the backend serve the Flutter Web client: diff --git a/lib/features/machines/deploy_backend_screen.dart b/lib/features/machines/deploy_backend_screen.dart index 439ad1d..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、OpenCode 等)。' - '可在主机上登录,兼容的 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, ' - 'OpenCode, …). 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/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/server/.env.example b/server/.env.example index 4c8e3bb..c348c23 100644 --- a/server/.env.example +++ b/server/.env.example @@ -41,12 +41,12 @@ RELAY_DEFAULT_DIR= # Max runtime for one CLI agent turn. Default: 3600000 (60 minutes). AGENT_TIMEOUT_MS=3600000 -# Claude runs as a persistent session: one CLI process per chat, kept alive -# between turns like a terminal, so follow-ups skip the cold start and anything -# started in the background keeps running. Each live process costs roughly -# 300 MB, so idle ones are closed and there is a hard cap on how many exist at -# once; a chat whose process was closed simply resumes on its next turn. -# A chat and its /btw side chat count as two. Turns past the cap wait for a slot. +# 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 @@ -56,12 +56,10 @@ AGENT_TIMEOUT_MS=3600000 # 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 -# cost of booting the CLI — around 360 MB for opencode, 90 MB for hermes — 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 simply -# reloads on its next turn. The cap below is per agent and counts live sessions; -# turns past it wait for a slot. Note that hermes runs one turn at a time per -# process, so concurrent hermes chats queue. +# 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 @@ -71,10 +69,14 @@ 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= @@ -85,10 +87,13 @@ 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= # Optional Windows override for directory zip downloads. Defaults to powershell.exe. @@ -100,8 +105,9 @@ 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 cheapest model) to restart it, -# then waits for the next reset. Set to false to disable the ping. +# 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= diff --git a/server/lib/agent-options.js b/server/lib/agent-options.js index ffae77b..8c24ad4 100644 --- a/server/lib/agent-options.js +++ b/server/lib/agent-options.js @@ -1,9 +1,9 @@ '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. // Hermes, for example, has no per-invocation model or effort flag. diff --git a/server/lib/agents.js b/server/lib/agents.js index 075d1b5..5e0b953 100644 --- a/server/lib/agents.js +++ b/server/lib/agents.js @@ -283,8 +283,8 @@ const codexPool = createCodexSessionPool({ }, }); -// Agents whose sessions Relay hosts itself, so deleting a chat can really -// delete the machine-side conversation instead of just forgetting its id. +// 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, @@ -292,10 +292,9 @@ const SESSION_POOLS = { codex: codexPool, }; -// Delete a scope's conversation for good. Deleting a chat in the app means the -// conversation is gone, so a pooled scope also loses its CLI-side transcript: -// without that, the id would be forgotten while the transcript lingered on -// disk, resumable forever. +// 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); diff --git a/server/lib/claude-session-pool.js b/server/lib/claude-session-pool.js index d4620ec..20eb128 100644 --- a/server/lib/claude-session-pool.js +++ b/server/lib/claude-session-pool.js @@ -393,8 +393,8 @@ function createClaudeSessionPool(options = {}) { } } - // Drop the live process for a scope. `sessionId` additionally deletes the - // stored transcript, so a session the user deleted can never be resumed. + // 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; diff --git a/server/lib/group-turn.js b/server/lib/group-turn.js index 9a8df60..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) { @@ -99,7 +99,7 @@ function lineFor(message, labelFor) { // 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. +// a prompt that exceeds the request budget. function buildGroupPrompt({ selfLabel, persona, @@ -160,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 f0d6159..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: diff --git a/server/lib/model-discovery.js b/server/lib/model-discovery.js index 31f3af3..1b5a584 100644 --- a/server/lib/model-discovery.js +++ b/server/lib/model-discovery.js @@ -61,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) { diff --git a/server/lib/quota-keepalive.js b/server/lib/quota-keepalive.js index 617f15a..31a7406 100644 --- a/server/lib/quota-keepalive.js +++ b/server/lib/quota-keepalive.js @@ -50,7 +50,8 @@ function readClaudeFiveHour(report) { } // Keeps Claude Code's five-hour window cycling so the usage screen never has to -// report "unknown". Mirrors what Codex gets for free from its header probe. +// 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, diff --git a/server/lib/stdio-agent-pool.js b/server/lib/stdio-agent-pool.js index e761b08..df7d9e5 100644 --- a/server/lib/stdio-agent-pool.js +++ b/server/lib/stdio-agent-pool.js @@ -14,9 +14,8 @@ const os = require('os'); // 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. That matters: opencode costs ~360MB just to boot, and paying that once -// instead of once per chat is the difference between three chats costing 1.5GB -// and costing 750MB. +// 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 @@ -27,9 +26,8 @@ const os = require('os'); // 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. opencode costs ~130MB per session on top of its -// ~360MB base, so four is roughly the same memory ceiling as the Claude pool's -// three processes. +// 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. @@ -639,8 +637,8 @@ function createStdioAgentPool(options = {}) { }); } - // Drop the live session for a scope. `purge` additionally deletes the agent's - // own stored transcript, so a session the user deleted can never be resumed. + // 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; diff --git a/server/lib/usage.js b/server/lib/usage.js index 014ad91..d15db61 100644 --- a/server/lib/usage.js +++ b/server/lib/usage.js @@ -12,8 +12,9 @@ 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 the -// cheapest model and the smallest possible completion. +// 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 = @@ -358,8 +359,8 @@ async function getClaudeUsage() { // 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 cheapest model, using the -// same OAuth credential and Claude Code identity as the usage query above. +// 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}`, diff --git a/server/routes/chat.js b/server/routes/chat.js index d852612..af0f028 100644 --- a/server/routes/chat.js +++ b/server/routes/chat.js @@ -245,7 +245,8 @@ 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. + // 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(); diff --git a/server/routes/group.js b/server/routes/group.js index 3a73db4..9b59d02 100644 --- a/server/routes/group.js +++ b/server/routes/group.js @@ -18,12 +18,11 @@ 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'; diff --git a/server/server.js b/server/server.js index 3d75a9b..580924e 100644 --- a/server/server.js +++ b/server/server.js @@ -126,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, @@ -247,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({ @@ -905,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'); @@ -914,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'); }, })); From fa863e7074fe5b320d63174631ad364703625732 Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Tue, 18 Aug 2026 21:33:56 -0400 Subject: [PATCH 10/12] docs: redesign readme with visual tour --- README.md | 254 +++++++++++----------- README.zh-CN.md | 218 ++++++++++--------- assets/screenshots/relay-chat-mobile.png | Bin 0 -> 78236 bytes assets/screenshots/relay-chat-web.png | Bin 0 -> 121861 bytes assets/screenshots/relay-files-mobile.png | Bin 0 -> 55883 bytes assets/screenshots/relay-overview-web.png | Bin 0 -> 105539 bytes assets/screenshots/relay-swarm-mobile.png | Bin 0 -> 56688 bytes 7 files changed, 245 insertions(+), 227 deletions(-) create mode 100644 assets/screenshots/relay-chat-mobile.png create mode 100644 assets/screenshots/relay-chat-web.png create mode 100644 assets/screenshots/relay-files-mobile.png create mode 100644 assets/screenshots/relay-overview-web.png create mode 100644 assets/screenshots/relay-swarm-mobile.png diff --git a/README.md b/README.md index 3d9c0f8..7c39add 100644 --- a/README.md +++ b/README.md @@ -2,145 +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, 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 + -Claude Code and Codex are the primary integrations. OpenCode and Hermes are -available as experimental host-managed integrations. +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. -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. + + + + + + +
🖥️
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.
-```mermaid -flowchart LR - C["Phone · Web · Desktop"] -->|"encrypted device credential"| B["Your Relay backend"] - B --> A["Claude Code · Codex · OpenCode · Hermes"] - B --> F["Your projects and files"] -``` +## See Relay in 60 seconds -## Current capabilities - -- **Live agent chat.** Stream replies, cancel turns, preserve multi-part agent - updates, and continue long work while switching between conversations. -- **Persistent agent sessions.** Every agent keeps a live CLI session between - messages, the way a terminal does, so follow-up turns skip the cold start, - and cancelling a turn interrupts it instead of ending the conversation. - Background work survives normally for Claude, OpenCode, and Hermes; Codex - commands must detach into their own session because of its sandbox. -- **Named conversations.** Each workdir and agent supports up to eight persistent - sessions with shared cross-device history and running-state indicators. -- **History tools.** Search the current workdir across agents and sessions, jump - to the matching message, and export the current conversation as Markdown. -- **Agent status and credential expiry.** See installed/authenticated state for - all four agents, plus how many days are left on the Claude Code and Codex - OAuth credentials before you have to log in again on the backend host. All - four agents' credentials stay host-managed. -- **Device credential management.** A backend status panel lists device tokens, - last-use metadata, and lets you revoke a token before deleting its record. -- **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, choose their shared work - tree, give each member a model, effort, permission, nickname, and persona, - then summon members with - `@mentions`. Multiple members run in parallel from one transcript snapshot, and - members can hand the floor to each other by `@mentioning` a teammate in their - own reply — bounded so a pair cannot loop forever. Swarms can be saved and - imported as JSON templates. -- **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 - interactive shell per device token on the current backend machine. It is not - a connection to the host SSH daemon: the PTY runs directly as the backend OS - user. Web bundles a terminal monospace font so Chromium keeps normal - horizontal character spacing. -- **Quota workflows.** View Claude and Codex usage. Both can - queue one prompt for the next detected five-hour reset. The backend keeps - Claude's five-hour window cycling with a minimal request so its reset time is - not lost after an idle window. Codex usage probing and the optional Claude - keepalive both make small provider requests and can consume quota. -- **Notifications.** Live local/browser alerts plus optional Web Push and Android - FCM for configured deployments. +### Keep real coding sessions within reach -## Quick start +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. -### 1. Prepare a backend + + A persistent Claude Code conversation in the Relay Web client + -You need a Linux, macOS, or Windows machine with Node.js 18+ and at least one -supported CLI installed. Claude and Codex must be logged in; OpenCode and -Hermes provider setup is managed on that host. Unix hosts also need `zip` for -directory downloads; Linux needs PM2 plus the native build tools listed in the -[backend guide](backends/README.md#requirements). +### Chat, coordinate, and manage files from mobile -From the repository root, run the setup for the backend OS: + + + + + + + + + + + +
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/linux/setup.sh -``` +These screenshots were captured in Chromium against an isolated demo backend; they contain no production credentials or project data. -```bash -./backends/macos/setup.sh -``` +## 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 -See [backends/README.md](backends/README.md) for service commands and platform -details. +### 1. Prepare the backend machine -### 2. Import the device credential +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. -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. Camera scanning is -mobile-only; every platform supports image/file or pasted-JSON import. Generate -a separate credential for each device. +Run the setup command for your backend OS from the repository root: -The app's first connection screen also contains a **Deploy backend** walkthrough. +| Backend OS | Setup command | +|---|---| +| Linux | `./backends/linux/setup.sh` | +| macOS | `./backends/macos/setup.sh` | +| Windows PowerShell | `.\backends\windows\setup.ps1` | -### 3. Choose a workdir and agent +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. -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. +### 2. Import an encrypted device credential -## Security summary +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. -- 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. -- Quota reporting may read and refresh the Claude/Codex OAuth files on the host, - but token values are never returned by Relay's API. -- 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. +### 3. Pick a project and start working -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. +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). + +## Security boundary + +- 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 @@ -152,34 +162,28 @@ 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). GitHub Actions runs the analyzer and -both test suites on pull requests. - -## License - -Relay is released under the [MIT License](LICENSE). +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 9351eb6..3207feb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,124 +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、OpenCode 和 Hermes 继续运行在已经准备好项目、 -shell 与登录态的机器上,再通过同一个 Flutter app 从手机、Web 或桌面重新连接这些本地 -CLI 智能体,不需要把项目搬到托管服务。 + + Relay 首页,显示已连接的编程智能体、最近会话与多智能体蜂群 + -Claude Code 与 Codex 是主要集成;OpenCode 与 Hermes 目前作为由主机管理的实验性集成提供。 +Relay 把源代码、shell 权限和 CLI 登录凭据留在你控制的电脑上。手机、Web 与桌面共用 +一个 Flutter 客户端,连接运行在项目旁边的小型 Node.js 后端——没有 Relay 云账号, +也没有托管中间层。 -Relay 没有云端账号,也没有内置的默认后端。你自己运行 Node.js 后端、生成加密凭证, -再把凭证导入信任的客户端。 + + + + + + +
🖥️
代码在哪里,智能体就在哪里
项目和智能体始终留在你的后端主机上。
📱
一个客户端,覆盖所有屏幕
手机、Web 与桌面使用一致的操作界面。
🔐
从设计上保持私有
每台设备导入独立、加密且可撤销的凭证。
-```mermaid -flowchart LR - C["手机 · Web · 桌面"] -->|"加密的设备凭证"| B["你自己的 Relay 后端"] - B --> A["Claude Code · Codex · OpenCode · Hermes"] - B --> F["你的项目和文件"] -``` +## 60 秒看懂 Relay -## 当前能力 - -- **实时智能体聊天。** 流式显示回复、取消任务、保留多段 agent 更新;切换会话后长任务 - 仍可继续运行。 -- **常驻 agent 会话。** 每个 agent 在消息之间保持一个活的 CLI 会话,就像终端里那样: - 后续回合省掉冷启动,取消只是打断本回合而不会结束对话。Claude、OpenCode 与 Hermes - 的后台任务通常可以继续;Codex 因 sandbox 限制,需要让命令脱离到独立 session。 -- **命名会话。** 每个工作目录与 agent 最多有 8 个持久会话,聊天历史和运行状态可在 - 多设备间同步。 -- **历史工具。** 可在当前工作目录中跨 agent、跨会话搜索,直接跳到命中的消息,并把 - 当前会话导出为 Markdown。 -- **Agent 状态与凭据有效期。** 查看四种 agent 的安装和认证状态,并显示 Claude Code - 与 Codex 的 OAuth 凭据还有几天到期、过期了几天,以便及时到后端主机上重新登录。 - 四种 agent 的凭据都由后端主机管理。 -- **设备凭证管理。** 后端状态面板会列出设备 token 与最近使用信息,并支持先吊销、再 - 删除 token 记录。 -- **按 agent 配置。** 在输入区选择模型、思考深度和权限。Claude Code 与 Codex 还会 - 显示默认关闭的快速模式;快速响应可能消耗更多额度或产生更高费用。 -- **Codex 动态目录。** 从已安装 Codex CLI 的结构化元数据读取模型与每个模型支持的 - 思考档位,并提供安全的回退目录。 -- **蜂群。** 多个 agent 共享一份记录和选定的工作树;每位成员可设置模型、思考深度、 - 权限、昵称和人设。用 `@` 召唤成员,同一条消息中的多个成员会基于同一快照并行运行; - 成员也可以在自己的回复里 `@` 队友把发言权交出去,并有上限防止两人无限互相召唤。 - 蜂群还可保存和导入 JSON 模板。 -- **远程文件。** 浏览后端允许的绝对路径、切换工作目录、上传文件、下载文件或压缩文件夹。 -- **SSH 终端。** 从“管理凭证 → 进入SSH”打开当前后端机器上按设备 token 隔离、可恢复 - 的交互 shell。它并不连接主机的 SSH daemon,而是直接以后端系统用户运行 PTY。 - Web 端内置等宽终端字体,避免 Chromium 中的字符横向间距过大。 -- **额度工作流。** 查看 Claude 与 Codex 额度;两者都可以预约在 - 下一个检测到的 5 小时额度重置后自动发送一条消息。后端会用一次极小请求让 Claude - 的 5 小时窗口持续滚动,避免空闲后丢失重置时间。Codex 额度探测与可选的 Claude - keepalive 都会向供应商发出小请求,可能消耗额度。 -- **通知。** 在线时使用本地/浏览器通知;配置后还可使用 Web Push 和 Android FCM。 +### 随时接回真实的编程会话 -## 快速开始 +流式查看回复、取消当前回合、搜索历史、导出 Markdown;切换页面后,任务仍可继续。 +每个 `工作目录 + agent` 最多支持 8 个可恢复的命名会话。 -### 1. 准备后端 + + Relay Web 客户端中的持久 Claude Code 会话 + -准备一台安装了 Node.js 18+ 的 Linux、macOS 或 Windows 主机,并至少安装一个支持的 -CLI。Claude 与 Codex 需要登录;OpenCode 与 Hermes 的 provider 配置在主机完成。 -Unix 主机下载文件夹时还需要 `zip`;Linux 还需要 PM2 和 -[后端说明](backends/README.zh-CN.md#前置要求)列出的本地编译工具。 +### 在手机上聊天、协作和管理文件 -在仓库根目录运行后端系统对应的命令: + + + + + + + + + + + +
Relay 移动端智能体会话Relay 移动端多智能体蜂群Relay 移动端远程文件浏览器
持久会话
从任意地点继续长时间运行的 agent 任务。
多智能体蜂群
让不同职责的 agent 在同一份记录中协作。
远程文件
浏览、上传、下载并切换当前工作树。
-```bash -./backends/linux/setup.sh -``` +这些图片由 Chromium 连接隔离的演示后端截取,不包含生产凭据或真实项目数据。 -```bash -./backends/macos/setup.sh -``` +## 它是怎样连接起来的 -```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. 准备后端主机 -服务命令和各平台细节见 [backends/README.zh-CN.md](backends/README.zh-CN.md)。 +在 Linux、macOS 或 Windows 主机安装 Node.js 18+ 和至少一个支持的 CLI。Claude 与 +Codex 需要事先在这台主机登录;OpenCode 与 Hermes 使用主机上管理的 provider 配置。 -### 2. 导入设备凭证 +在仓库根目录执行对应系统的安装命令: -安装完成后会打印一张加密二维码,并在 `server/credentials/` 下保存 `.relay.png` / -`.relay.json`。通过相机、图片/文件或粘贴 JSON 导入,再输入生成时设置的密码。每台设备 -应单独生成一份凭证。相机扫描只在移动端提供;所有平台都支持导入图片/文件或粘贴 JSON。 +| 后端系统 | 安装命令 | +|---|---| +| Linux | `./backends/linux/setup.sh` | +| macOS | `./backends/macos/setup.sh` | +| Windows PowerShell | `.\backends\windows\setup.ps1` | -app 的首次连接页也内置了“部署后端”向导。 +安装器会引导你选择直连、正式 Cloudflare Tunnel 或临时 Quick Tunnel。直连服务公开 +暴露前必须配置 HTTPS。Linux 还需要 PM2 和 +[后端前置要求](backends/README.zh-CN.md#前置要求)列出的本地工具;Unix 主机下载文件夹 +时需要 `zip`。 -### 3. 选择工作目录与 agent +### 2. 导入加密设备凭证 -选择机器、设置后端工作目录,然后打开 agent 会话或蜂群。当前工作目录保存在每个客户端 -本地,并随每次 API 请求发送。 +安装程序会打印加密二维码,并在 `server/credentials/` 下写入 `.relay.png` / +`.relay.json`。通过相机、图片/文件或粘贴 JSON 导入,再输入生成时设置的密码。相机 +扫描仅移动端支持;所有客户端都可以导入文件或粘贴 JSON。建议为每台设备生成一份可 +独立撤销的凭证。 -## 安全摘要 +### 3. 选择项目并开始工作 -- 所有 HTTP API 都需要可撤销的 bearer token。 -- SSH 终端用该 token 换取短时、一次性的 WebSocket 票据,长期 bearer token 不会进入 - WebSocket 地址。 -- 凭证导出使用 PBKDF2-HMAC-SHA256 与 AES-256-GCM 加密。 -- 额度查询可能读取并刷新主机上的 Claude/Codex OAuth 文件,但 Relay API 不会返回 - token 值。 -- 文件 API 会拒绝一组明确的 Relay、SSH、Claude 与 Codex 敏感路径,并可用 - `RELAY_FS_ROOTS` 进一步限制。 -- 错误 token 尝试会被限速。 -- 公网部署应终止 TLS,并使用权限受限的非 root 系统用户运行 Relay。 +选择后端、设置工作目录,然后打开 agent 会话或蜂群。服务命令、网络配置与各平台说明 +见[后端安装指南](backends/README.zh-CN.md)。 -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)。 ## 开发 @@ -138,26 +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)。GitHub Actions 会在 pull request 上运行静态分析和 -两套测试。 - -## 许可证 - -Relay 使用 [MIT License](LICENSE) 发布。 +[CHANGELOG.md](CHANGELOG.md)。Relay 使用 [MIT License](LICENSE) 发布。 diff --git a/assets/screenshots/relay-chat-mobile.png b/assets/screenshots/relay-chat-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..323285c225a58ca21d2f9bb982e08717ecc7b0c4 GIT binary patch literal 78236 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/relay-swarm-mobile.png b/assets/screenshots/relay-swarm-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..162a394a52027b4cf4d40734fd4571edc83c6422 GIT binary patch literal 56688 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 m|2tGmS^xk521!IgR09ApUyyOnR#uk)0000 Date: Tue, 18 Aug 2026 21:44:59 -0400 Subject: [PATCH 11/12] release: v0.1.5 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5032c..eaef345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.1.5 - Unreleased +## 0.1.5 - 2026-08-19 ### Removed @@ -52,6 +52,10 @@ ### 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 From ed6b0e2ff47bedba02d887dd08f4fd70b1734f1f Mon Sep 17 00:00:00 2001 From: AgentDeck Date: Tue, 18 Aug 2026 21:56:23 -0400 Subject: [PATCH 12/12] fix(ci): enumerate backend tests portably --- server/package.json | 2 +- server/scripts/run-tests.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 server/scripts/run-tests.js diff --git a/server/package.json b/server/package.json index 2f1d92f..8494a86 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node server.js", "dev": "node server.js", - "test": "node --test \"test/*.test.js\"", + "test": "node scripts/run-tests.js", "credential": "node scripts/create-credential.js" }, "dependencies": { 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);

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 OMB3{5cPmuw!u|oZJ~KlA literal 0 HcmV?d00001 diff --git a/assets/screenshots/relay-chat-web.png b/assets/screenshots/relay-chat-web.png new file mode 100644 index 0000000000000000000000000000000000000000..1ce73542e0096983a4c8746d027474f5e5e35c4b GIT binary patch literal 121861 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%0D}<=wu3Xg= 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 Q;Wt!T>iTL$s<(pv2mZoL)c^nh literal 0 HcmV?d00001 diff --git a/assets/screenshots/relay-files-mobile.png b/assets/screenshots/relay-files-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..1fcbad15b419caea869f045274d887c1c485418f GIT binary patch literal 55883 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