mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f8a8414a | ||
|
|
9386901896 | ||
|
|
daefa8fb7f | ||
|
|
754f1facb6 | ||
|
|
727aa0cdac | ||
|
|
a387eaf26a | ||
|
|
a11c246bdc | ||
|
|
2e95003154 | ||
|
|
1b4483615a | ||
|
|
4d0fe57a7a | ||
|
|
84db0cecd7 | ||
|
|
14ac6984f3 | ||
|
|
b6147e39ab | ||
|
|
a2b743a6d3 | ||
|
|
7442323ca2 | ||
|
|
086b3098b1 | ||
|
|
e0f82e7ad0 | ||
|
|
9cee1d8196 | ||
|
|
c2a8e56d33 | ||
|
|
de33dfd034 | ||
|
|
917c2f56f4 | ||
|
|
a3822ca299 | ||
|
|
41fd45588f | ||
|
|
54b8126deb | ||
|
|
7c50b7a080 | ||
|
|
11b407c804 | ||
|
|
814098852c | ||
|
|
5c6f175db5 | ||
|
|
278acf5fe1 | ||
|
|
165ae58f75 | ||
|
|
a1f3256923 | ||
|
|
86bb5cc827 | ||
|
|
483312e1dc | ||
|
|
0b3c29b2ec | ||
|
|
1885d8602f | ||
|
|
57312d4f75 | ||
|
|
ffbb2ffd06 | ||
|
|
45a5ba8637 | ||
|
|
b8ade39f73 | ||
|
|
deca82653e | ||
|
|
4e9d80d118 | ||
|
|
918949c7fa | ||
|
|
c1df523a77 | ||
|
|
7a8f65805d | ||
|
|
89b87c0235 | ||
|
|
fc96b260d1 | ||
|
|
3fe79535cc | ||
|
|
d40e4b6f0d | ||
|
|
8f04e65cf1 | ||
|
|
c5295a4ab3 | ||
|
|
f59af4c810 | ||
|
|
e012c8f52e | ||
|
|
312a6e22fe | ||
|
|
7f92f7f51d | ||
|
|
200453f032 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -99,7 +99,22 @@ jobs:
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/server/agent/provider-registry.test.ts
|
||||
src/server/agent/provider-launch-config.test.ts
|
||||
src/server/agent/provider-snapshot-manager.test.ts
|
||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||
src/server/agent/providers/provider-windows-launch.test.ts
|
||||
src/server/agent/providers/provider-availability.test.ts
|
||||
src/server/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.59 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
||||
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
||||
|
||||
## 0.1.58 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
|
||||
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
||||
- `disallowedTools` option in provider config to block specific tools from an agent.
|
||||
|
||||
### Improved
|
||||
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
||||
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
|
||||
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
||||
|
||||
### Fixed
|
||||
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
||||
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
|
||||
- Workspace headers no longer show a branch icon for non-git workspaces.
|
||||
- Branch switcher layout is stable on mobile.
|
||||
- Model names no longer truncate mid-word in the picker rows.
|
||||
- Messages appear in the correct order after reconnecting on mobile.
|
||||
- Clearing agent attention no longer throws on timeout.
|
||||
|
||||
## 0.1.56 - 2026-04-14
|
||||
|
||||
### Fixed
|
||||
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
||||
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
||||
|
||||
## 0.1.55 - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- **Always run typecheck after every change.**
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
|
||||
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
|
||||
listen: "127.0.0.1:0", // OS picks a free port
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -107,6 +107,7 @@ Required fields for custom providers:
|
||||
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
||||
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
||||
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Automated setup is also available: `npx @z_ai/coding-helper`
|
||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||
|
||||
@@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
|
||||
- API keys must be created in the **Singapore region**
|
||||
- The coding plan is for personal use only in interactive coding tools
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||
|
||||
---
|
||||
@@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields:
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
@@ -460,6 +463,29 @@ Each entry in the `models` array:
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-proxy": {
|
||||
"extends": "claude",
|
||||
"label": "My Proxy",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
allowedHosts: true | string[],
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
|
||||
@@ -139,6 +139,43 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
|
||||
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||
|
||||
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
|
||||
|
||||
```markdown
|
||||
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
|
||||
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
|
||||
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
@@ -9,6 +9,10 @@ let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||
];
|
||||
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
@@ -58,12 +62,12 @@ in
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
hostnames = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
@@ -141,10 +145,10 @@ in
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE=";
|
||||
npmDepsHash = "sha256-rpXng5y99bDt7a3f6vI+EWrVahlXINdHzCf7ml+ZnFs=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
63
package-lock.json
generated
63
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.56",
|
||||
"@getpaseo/highlight": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.59",
|
||||
"@getpaseo/highlight": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35056,11 +35056,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/relay": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35101,11 +35101,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/cli": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35139,7 +35139,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35340,7 +35340,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35366,7 +35366,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35382,14 +35382,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.56",
|
||||
"@getpaseo/relay": "0.1.56",
|
||||
"@getpaseo/highlight": "0.1.59",
|
||||
"@getpaseo/relay": "0.1.59",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35417,6 +35417,7 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
@@ -35640,6 +35641,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
@@ -35794,6 +35804,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
@@ -35817,7 +35842,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.56",
|
||||
"@getpaseo/highlight": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.59",
|
||||
"@getpaseo/highlight": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
[contenteditable='true'] {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid #20744A;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -16,6 +16,35 @@ import {
|
||||
import { X } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
let escListenerAttached = false;
|
||||
|
||||
function handleEscKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
const top = escStack[escStack.length - 1];
|
||||
if (!top) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
top();
|
||||
}
|
||||
|
||||
function pushEscHandler(handler: EscHandler): () => void {
|
||||
escStack.push(handler);
|
||||
if (!escListenerAttached && typeof window !== "undefined") {
|
||||
window.addEventListener("keydown", handleEscKeyDown, true);
|
||||
escListenerAttached = true;
|
||||
}
|
||||
return () => {
|
||||
const index = escStack.lastIndexOf(handler);
|
||||
if (index !== -1) escStack.splice(index, 1);
|
||||
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
|
||||
window.removeEventListener("keydown", handleEscKeyDown, true);
|
||||
escListenerAttached = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginLeft: theme.spacing[3],
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
bottomSheetStaticContent: {
|
||||
flex: 1,
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopStaticContent: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -116,9 +167,11 @@ export function AdaptiveModalSheet({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
scrollable = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -157,6 +210,11 @@ export function AdaptiveModalSheet({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(onClose);
|
||||
}, [visible, isMobile, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -174,18 +232,25 @@ export function AdaptiveModalSheet({
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -199,19 +264,26 @@ export function AdaptiveModalSheet({
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -98,7 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
@@ -421,7 +421,7 @@ export function AgentInputArea({
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isInputActive) {
|
||||
if (!isPaneFocused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ export function AgentInputArea({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isInputActive],
|
||||
[isPaneFocused],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -475,9 +475,9 @@ export function AgentInputArea({
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isInputActive,
|
||||
enabled: isPaneFocused,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isInputActive,
|
||||
isActive: () => isPaneFocused,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
@@ -736,7 +736,7 @@ export function AgentInputArea({
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.contentScroll}
|
||||
style={[styles.contentScroll, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.contentScrollContainer}
|
||||
>
|
||||
{artifact.type === "image" ? (
|
||||
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.codeContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -43,12 +43,17 @@ export function BranchSwitcher({
|
||||
queryClient,
|
||||
});
|
||||
|
||||
if (!currentBranchName) {
|
||||
return (
|
||||
const titleContent = (
|
||||
<>
|
||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
</>
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -63,10 +68,7 @@ export function BranchSwitcher({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
@@ -117,7 +119,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingVertical: {
|
||||
xs: 0,
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -14,12 +15,14 @@ export function DiffScroll({
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={style}
|
||||
style={[style, webScrollbarStyle]}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -23,6 +24,7 @@ export function DiffViewer({
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
@@ -38,6 +40,7 @@ export function DiffViewer({
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && { maxHeight },
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -47,6 +50,7 @@ export function DiffViewer({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
|
||||
@@ -167,23 +167,32 @@ export function FileExplorerPane({
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
|
||||
// If the root listing fails (e.g. client not yet connected), we reset the
|
||||
// flag so the next time requestDirectoryListing is recreated (when client
|
||||
// becomes available) this effect retries automatically.
|
||||
hasInitializedRef.current = true;
|
||||
void requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
const persistedPaths =
|
||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}).then((succeeded) => {
|
||||
if (!succeeded) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const persistedPaths =
|
||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
|
||||
23
packages/app/src/components/file-pane-render-mode.test.ts
Normal file
23
packages/app/src/components/file-pane-render-mode.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
|
||||
describe("isRenderedMarkdownFile", () => {
|
||||
it("detects .md files", () => {
|
||||
expect(isRenderedMarkdownFile("README.md")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects .markdown files", () => {
|
||||
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat .mdx files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat other text files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
4
packages/app/src/components/file-pane-render-mode.ts
Normal file
4
packages/app/src/components/file-pane-render-mode.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
@@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
@@ -20,7 +22,9 @@ import {
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
|
||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||
</View>
|
||||
<Text style={codeLineStyles.lineText}>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{tokens.map((token, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
@@ -117,19 +121,23 @@ function FilePreviewBody({
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text") {
|
||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [preview?.kind, preview?.content, filePath]);
|
||||
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
@@ -154,6 +162,28 @@ function FilePreviewBody({
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (isMarkdownFile) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||
const codeLines = (
|
||||
<View>
|
||||
@@ -188,6 +218,7 @@ function FilePreviewBody({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
@@ -264,6 +295,7 @@ export function FilePane({
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewMarkdownScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -42,7 +42,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import {
|
||||
markScrollInvestigationEvent,
|
||||
markScrollInvestigationRender,
|
||||
@@ -75,8 +78,8 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isInputActive?: boolean;
|
||||
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isPaneFocused?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
@@ -206,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
isPaneFocused = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
@@ -233,7 +236,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const queueKeys = useShortcutKeys("message-input-queue");
|
||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const rootRef = useRef<View | null>(null);
|
||||
const inputWrapperRef = useRef<View | null>(null);
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||
@@ -427,7 +432,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isInputActive },
|
||||
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
@@ -891,7 +896,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
||||
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
||||
// cleared before the keydown fires.
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||
if (onKeyPressCallback) {
|
||||
@@ -997,10 +1002,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true;
|
||||
setIsInputFocused(true);
|
||||
onFocusChange?.(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false;
|
||||
setIsInputFocused(false);
|
||||
onFocusChange?.(false);
|
||||
}}
|
||||
style={[
|
||||
@@ -1025,6 +1032,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Button row */}
|
||||
@@ -1270,6 +1282,14 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
focusHintText: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.5,
|
||||
},
|
||||
textInput: {
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { AlertCircle, Search } from "lucide-react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
const providerEntry = useMemo(
|
||||
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||
[snapshotEntries, provider],
|
||||
);
|
||||
const models = providerEntry?.models ?? [];
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client || !provider) return;
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
if (!providerEntry?.fetchedAt) return null;
|
||||
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||
// clockTick triggers re-computation on timer
|
||||
}, [providerEntry?.fetchedAt, clockTick]);
|
||||
|
||||
setLoading(true);
|
||||
setDiagnostic(null);
|
||||
const q = query.trim().toLowerCase();
|
||||
const filteredModels = q
|
||||
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||
: models;
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider]);
|
||||
const fetchDiagnostic = useCallback(
|
||||
async (options?: { keepCurrent?: boolean }) => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
setLoading(true);
|
||||
if (!options?.keepCurrent) {
|
||||
setDiagnostic(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[client, provider],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
setQuery("");
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
function renderModelsBody() {
|
||||
if (models.length === 0 && providerSnapshotRefreshing) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (models.length === 0 && providerErrorMessage) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models detected.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filteredModels.length === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return filteredModels.map((model: AgentModelDefinition, index) => (
|
||||
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
|
||||
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
|
||||
{model.id}
|
||||
</Text>
|
||||
</View>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title={providerLabel}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={["50%", "85%"]}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={sheetStyles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
||||
</View>
|
||||
) : diagnostic ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={sheetStyles.scrollContainer}
|
||||
contentContainerStyle={sheetStyles.scrollContent}
|
||||
scrollable={false}
|
||||
headerActions={
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={refreshInFlight}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
sheetStyles.iconButton,
|
||||
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
>
|
||||
<Text style={sheetStyles.diagnosticText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
<View style={sheetStyles.section}>
|
||||
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
|
||||
<View style={sheetStyles.codeBlock}>
|
||||
{loading && !diagnostic ? (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||
</View>
|
||||
) : diagnostic ? (
|
||||
<ScrollView
|
||||
style={sheetStyles.codeScroll}
|
||||
contentContainerStyle={sheetStyles.codeContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<Text style={sheetStyles.codeText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={sheetStyles.modelsSection}>
|
||||
<View style={sheetStyles.modelsHeader}>
|
||||
<Text style={sheetStyles.sectionTitle}>Models</Text>
|
||||
<View style={sheetStyles.modelsHeaderMeta}>
|
||||
<Text style={sheetStyles.countText}>{models.length}</Text>
|
||||
{fetchedAtLabel ? (
|
||||
<>
|
||||
<Text style={sheetStyles.metaDot}>·</Text>
|
||||
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{models.length > 0 ? (
|
||||
<View style={sheetStyles.searchContainer}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<AdaptiveTextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search models"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<ScrollView
|
||||
style={sheetStyles.modelsScroll}
|
||||
contentContainerStyle={sheetStyles.modelsScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderModelsBody()}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetStyles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
codeBlock: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
maxHeight: 180,
|
||||
},
|
||||
codeScroll: {
|
||||
maxHeight: 180,
|
||||
},
|
||||
codeContent: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
codeBlockLoading: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
modelsSection: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
modelsHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
modelsHeaderMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
countText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
modelsScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
modelsScrollContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
modelRow: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
modelRowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
modelLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
modelId: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: Fonts.mono,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
diagnosticText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
lineHeight: theme.fontSize.sm * 1.6,
|
||||
},
|
||||
}));
|
||||
|
||||
67
packages/app/src/components/spinning-refresh-icon.tsx
Normal file
67
packages/app/src/components/spinning-refresh-icon.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from "react";
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface SpinningRefreshIconProps {
|
||||
spinning: boolean;
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
||||
const rotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (spinning) {
|
||||
rotation.value = 0;
|
||||
rotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(rotation);
|
||||
const remainder = rotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
rotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
rotation.value = withTiming(360, {
|
||||
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
||||
easing: Easing.linear,
|
||||
});
|
||||
}, [rotation, spinning]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<RefreshCw size={size} color={color} />
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -30,6 +31,7 @@ export function ToolCallDetailsContent({
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
// Compute diff lines for edit type
|
||||
const diffLines = useMemo(() => {
|
||||
@@ -65,6 +67,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -74,6 +77,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -99,6 +103,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -108,6 +113,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -137,6 +143,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -146,6 +153,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -181,12 +189,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -204,12 +218,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -236,12 +256,18 @@ export function ToolCallDetailsContent({
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -286,12 +312,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||
</Text>
|
||||
@@ -356,7 +388,7 @@ export function ToolCallDetailsContent({
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={styles.jsonScroll}
|
||||
style={[styles.jsonScroll, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
@@ -378,7 +410,7 @@ export function ToolCallDetailsContent({
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={[styles.jsonScroll, styles.jsonScrollError]}
|
||||
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
|
||||
@@ -890,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 0,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -348,6 +349,7 @@ export function ContextMenuContent({
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
@@ -537,6 +539,7 @@ export function ContextMenuContent({
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -267,6 +268,7 @@ export function DropdownMenuContent({
|
||||
}>): ReactElement | null {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -214,12 +219,24 @@ export function processTimelineResponse(
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
// Flush head to tail before appending canonical entries so that
|
||||
// chronological ordering is preserved. This matters on mobile where
|
||||
// the server drops live events for backgrounded/unfocused agents:
|
||||
// when the client catches up, the head may still hold stale live
|
||||
// items from before the gap that must be committed ahead of the
|
||||
// canonical gap-fill entries.
|
||||
const baseTail =
|
||||
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
|
||||
if (currentHead.length > 0) {
|
||||
nextHead = [];
|
||||
}
|
||||
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
baseTail,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useAgentAttentionClear({
|
||||
return;
|
||||
}
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
client.clearAgentAttention(resolvedAgentId).catch(() => {});
|
||||
},
|
||||
[agentId, attentionReason, client, isConnected, requiresAttention],
|
||||
);
|
||||
|
||||
@@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => {
|
||||
async (
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
): Promise<boolean> => {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
||||
@@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: "Workspace is unavailable",
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
@@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: "Host is not connected",
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
|
||||
return nextState;
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
@@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey],
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -323,6 +324,12 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
@@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
invalidate: () => void;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
||||
},
|
||||
});
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async (providers?: AgentProvider[]) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.refreshProvidersSnapshot({ providers });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||
return;
|
||||
@@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
||||
});
|
||||
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot();
|
||||
}, [client]);
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
await refreshSnapshot(providers);
|
||||
},
|
||||
[refreshSnapshot],
|
||||
);
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
@@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
isLoading: snapshotQuery.isLoading,
|
||||
isFetching: snapshotQuery.isFetching,
|
||||
isRefreshing,
|
||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||
supportsSnapshot,
|
||||
refresh,
|
||||
|
||||
1
packages/app/src/hooks/use-web-scrollbar-style.d.ts
vendored
Normal file
1
packages/app/src/hooks/use-web-scrollbar-style.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./use-web-scrollbar-style.web";
|
||||
5
packages/app/src/hooks/use-web-scrollbar-style.native.ts
Normal file
5
packages/app/src/hooks/use-web-scrollbar-style.native.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ViewStyle } from "react-native";
|
||||
|
||||
export function useWebScrollbarStyle(): ViewStyle | undefined {
|
||||
return undefined;
|
||||
}
|
||||
21
packages/app/src/hooks/use-web-scrollbar-style.web.ts
Normal file
21
packages/app/src/hooks/use-web-scrollbar-style.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
// CSS scrollbar properties are supported by React Native Web at runtime
|
||||
// but are not included in React Native's ViewStyle type definition.
|
||||
interface WebScrollbarStyle extends ViewStyle {
|
||||
scrollbarColor: string;
|
||||
scrollbarWidth: string;
|
||||
}
|
||||
|
||||
export function useWebScrollbarStyle(): WebScrollbarStyle {
|
||||
const { theme } = useUnistyles();
|
||||
return useMemo(
|
||||
(): WebScrollbarStyle => ({
|
||||
scrollbarColor: `${theme.colors.scrollbarHandle} transparent`,
|
||||
scrollbarWidth: "thin",
|
||||
}),
|
||||
[theme.colors.scrollbarHandle],
|
||||
);
|
||||
}
|
||||
@@ -791,6 +791,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- Message input ---
|
||||
{
|
||||
id: "message-input-focus-cmd-l-mac",
|
||||
action: "message-input.action",
|
||||
combo: "Cmd+L",
|
||||
when: { mac: true, commandCenter: false },
|
||||
payload: { type: "message-input", kind: "focus" },
|
||||
help: {
|
||||
id: "focus-message-input",
|
||||
section: "agent-input",
|
||||
label: "Focus message input",
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-focus-ctrl-l-non-mac",
|
||||
action: "message-input.action",
|
||||
combo: "Ctrl+L",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
payload: { type: "message-input", kind: "focus" },
|
||||
help: {
|
||||
id: "focus-message-input",
|
||||
section: "agent-input",
|
||||
label: "Focus message input",
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||
action: "message-input.action",
|
||||
|
||||
@@ -762,7 +762,7 @@ function AgentPanelBody({
|
||||
<AgentInputArea
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
images={agentInputDraft.images}
|
||||
|
||||
@@ -1232,7 +1232,7 @@ function DraftAgentScreenContent({
|
||||
<AgentInputArea
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isInputActive={isFocused}
|
||||
isPaneFocused={isFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -521,9 +522,11 @@ interface ProvidersSectionProps {
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
const providerRefreshInFlight =
|
||||
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
@@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
|
||||
onPress={() => {
|
||||
void refresh();
|
||||
}}
|
||||
disabled={providerRefreshInFlight}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
settingsStyles.sectionHeaderLink,
|
||||
styles.providerRefreshButton,
|
||||
(hovered || pressed) && styles.providerRefreshButtonHovered,
|
||||
providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh providers"
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
<SpinningRefreshIcon
|
||||
spinning={providerRefreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
|
||||
const modelCount = entry?.models?.length ?? 0;
|
||||
|
||||
return (
|
||||
<View key={def.id} style={styles.audioRow}>
|
||||
<Pressable
|
||||
key={def.id}
|
||||
style={styles.audioRow}
|
||||
onPress={() => setDiagnosticProvider(def.id)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={styles.audioRowContent}>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||
@@ -584,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
{status === "ready" && modelCount > 0 ? (
|
||||
<Text style={styles.audioRowSubtitle}>
|
||||
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.providerActions}>
|
||||
<StatusBadge
|
||||
label={
|
||||
status === "ready"
|
||||
? "Available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status === "loading"
|
||||
? "Loading..."
|
||||
: "Not installed"
|
||||
}
|
||||
variant={
|
||||
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => setDiagnosticProvider(def.id)}
|
||||
>
|
||||
Diagnostic
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<StatusBadge
|
||||
label={
|
||||
status === "ready"
|
||||
? "Available"
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status === "loading"
|
||||
? "Loading..."
|
||||
: "Not installed"
|
||||
}
|
||||
variant={
|
||||
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
formButtonPrimaryText: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
providerRefreshButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
justifyContent: "center",
|
||||
},
|
||||
providerRefreshButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerRefreshButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
// Audio settings card
|
||||
audioCard: {
|
||||
overflow: "hidden",
|
||||
@@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
providerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Fonts } from "@/constants/theme";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
type StartupSplashScreenProps = {
|
||||
bootstrapState?: {
|
||||
@@ -161,6 +162,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
@@ -282,7 +284,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<View style={styles.errorScreen}>
|
||||
<TitlebarDragRegion />
|
||||
<ScrollView
|
||||
style={styles.errorScrollView}
|
||||
style={[styles.errorScrollView, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.errorScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
@@ -303,7 +305,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={styles.logsScroll}
|
||||
style={[styles.logsScroll, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
|
||||
@@ -342,7 +342,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentInputArea
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -731,12 +731,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
};
|
||||
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
||||
|
||||
const isCheckoutQueryEnabled =
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
isAbsolutePath(normalizedWorkspaceId);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
||||
enabled:
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
isAbsolutePath(normalizedWorkspaceId),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
@@ -745,6 +746,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
const isCheckoutStatusLoading =
|
||||
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
||||
|
||||
const workspaceDescriptor = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
|
||||
@@ -758,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const workspaceHeader = workspaceDescriptor
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
|
||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||
|
||||
@@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
|
||||
/**
|
||||
* Flush head items to tail, avoiding duplicates.
|
||||
*/
|
||||
function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
if (head.length === 0) {
|
||||
return tail;
|
||||
}
|
||||
|
||||
31
packages/app/src/utils/keyboard-ime.test.ts
Normal file
31
packages/app/src/utils/keyboard-ime.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isImeComposingKeyboardEvent } from "./keyboard-ime";
|
||||
|
||||
describe("isImeComposingKeyboardEvent", () => {
|
||||
it("ignores events while IME composition is active", () => {
|
||||
expect(
|
||||
isImeComposingKeyboardEvent({
|
||||
isComposing: true,
|
||||
keyCode: 13,
|
||||
} as KeyboardEvent),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores Chromium IME fallback events with keyCode 229", () => {
|
||||
expect(
|
||||
isImeComposingKeyboardEvent({
|
||||
isComposing: false,
|
||||
keyCode: 229,
|
||||
} as KeyboardEvent),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps regular keyboard events eligible for shortcuts", () => {
|
||||
expect(
|
||||
isImeComposingKeyboardEvent({
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
6
packages/app/src/utils/keyboard-ime.ts
Normal file
6
packages/app/src/utils/keyboard-ime.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function isImeComposingKeyboardEvent(event: {
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
}): boolean {
|
||||
return Boolean(event.isComposing) || event.keyCode === 229;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/relay": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
@@ -124,10 +124,27 @@ export function createCli(): Command {
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.action(withOutput(runDaemonRestartCommand));
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(
|
||||
withOutput((...args) => {
|
||||
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||
return runDaemonRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames:
|
||||
typeof options.hostnames === "string"
|
||||
? options.hostnames
|
||||
: typeof options.allowedHosts === "string"
|
||||
? options.allowedHosts
|
||||
: undefined,
|
||||
},
|
||||
command,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
program.addCommand(createAgentCommand());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import { startCommand } from "./start.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
import { runStopCommand } from "./stop.js";
|
||||
@@ -36,10 +36,27 @@ export function createDaemonCommand(): Command {
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.action(withOutput(runRestartCommand));
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(
|
||||
withOutput((...args) => {
|
||||
const [options, command] = args.slice(-2) as [(typeof args)[number], Command];
|
||||
return runRestartCommand(
|
||||
{
|
||||
...options,
|
||||
hostnames:
|
||||
typeof options.hostnames === "string"
|
||||
? options.hostnames
|
||||
: typeof options.allowedHosts === "string"
|
||||
? options.allowedHosts
|
||||
: undefined,
|
||||
},
|
||||
command,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface DaemonStartOptions {
|
||||
relay?: boolean;
|
||||
mcp?: boolean;
|
||||
injectMcp?: boolean;
|
||||
allowedHosts?: string;
|
||||
hostnames?: string;
|
||||
}
|
||||
|
||||
export interface LocalDaemonPidInfo {
|
||||
@@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
|
||||
}
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
|
||||
if (options.hostnames) {
|
||||
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
||||
}
|
||||
return childEnv;
|
||||
}
|
||||
@@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
||||
...envWithHome(options.home),
|
||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||
PASEO_LISTEN: undefined,
|
||||
PASEO_HOSTNAMES: undefined,
|
||||
PASEO_ALLOWED_HOSTS: undefined,
|
||||
};
|
||||
const home = resolvePaseoHome(env);
|
||||
|
||||
@@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions {
|
||||
relay: typeof options.relay === "boolean" ? options.relay : undefined,
|
||||
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
|
||||
injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined,
|
||||
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
|
||||
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
|
||||
};
|
||||
|
||||
if (startOptions.listen && startOptions.port) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
startLocalDaemonForeground,
|
||||
@@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js";
|
||||
|
||||
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
|
||||
|
||||
type RawStartCommandOptions = StartOptions & {
|
||||
allowedHosts?: string;
|
||||
};
|
||||
|
||||
export function startCommand(): Command {
|
||||
return new Command("start")
|
||||
.description("Start the local Paseo daemon")
|
||||
@@ -20,11 +24,15 @@ export function startCommand(): Command {
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.action(async (options: StartOptions) => {
|
||||
await runStart(options);
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(async (options: RawStartCommandOptions) => {
|
||||
await runStart({
|
||||
...options,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
||||
import { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
@@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions {
|
||||
voice?: "ask" | "enable" | "disable";
|
||||
}
|
||||
|
||||
type RawOnboardOptions = OnboardOptions & {
|
||||
allowedHosts?: string;
|
||||
};
|
||||
|
||||
type OnboardPersistedConfig = PersistedConfig & {
|
||||
features?: PersistedConfig["features"] & {
|
||||
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
|
||||
@@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number {
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {};
|
||||
|
||||
if (options.listen) {
|
||||
@@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||
cliOverrides.relayEnabled = false;
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
const raw = options.allowedHosts.trim();
|
||||
cliOverrides.allowedHosts =
|
||||
if (options.hostnames) {
|
||||
const raw = options.hostnames.trim();
|
||||
cliOverrides.hostnames =
|
||||
raw.toLowerCase() === "true"
|
||||
? true
|
||||
: raw
|
||||
@@ -297,13 +301,17 @@ export function onboardCommand(): Command {
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option(
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
)
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
|
||||
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
|
||||
.action(async (options: OnboardOptions) => {
|
||||
await runOnboard(options);
|
||||
.action(async (options: RawOnboardOptions) => {
|
||||
await runOnboard({
|
||||
...options,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ type ProviderModel = {
|
||||
};
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
{
|
||||
id: "claude-opus-4-7[1m]",
|
||||
model: "Opus 4.7 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
model: "Opus 4.7",
|
||||
descriptionFragment: "Latest release",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6[1m]",
|
||||
model: "Opus 4.6 1M",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -13,8 +13,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.56",
|
||||
"@getpaseo/server": "0.1.56",
|
||||
"@getpaseo/cli": "0.1.59",
|
||||
"@getpaseo/server": "0.1.59",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -13,6 +13,11 @@ npm run build:main
|
||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
||||
export EXPO_PORT
|
||||
|
||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||
# the daemon websocket. Safe here because this script is development-only
|
||||
# and the daemon still binds to localhost.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.59",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.56",
|
||||
"@getpaseo/relay": "0.1.56",
|
||||
"@getpaseo/highlight": "0.1.59",
|
||||
"@getpaseo/relay": "0.1.59",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -93,6 +93,7 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
|
||||
@@ -1187,8 +1187,28 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
clearAgentAttention(agentId: string | string[]): void {
|
||||
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
|
||||
async clearAgentAttention(agentId: string | string[]): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "clear_agent_attention",
|
||||
agentId,
|
||||
requestId,
|
||||
});
|
||||
await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "clear_agent_attention_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sendHeartbeat(params: {
|
||||
@@ -1692,7 +1712,27 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async cancelAgent(agentId: string): Promise<void> {
|
||||
this.sendSessionMessage({ type: "cancel_agent_request", agentId });
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "cancel_agent_request",
|
||||
agentId,
|
||||
requestId,
|
||||
});
|
||||
await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "cancel_agent_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
@@ -2716,6 +2756,7 @@ export class DaemonClient {
|
||||
|
||||
async refreshProvidersSnapshot(options?: {
|
||||
cwd?: string;
|
||||
providers?: AgentProvider[];
|
||||
requestId?: string;
|
||||
}): Promise<RefreshProvidersSnapshotPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -2723,9 +2764,10 @@ export class DaemonClient {
|
||||
message: {
|
||||
type: "refresh_providers_snapshot_request",
|
||||
cwd: options?.cwd,
|
||||
providers: options?.providers,
|
||||
},
|
||||
responseType: "refresh_providers_snapshot_response",
|
||||
timeout: 5000,
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
80
packages/server/src/server/agent/agent-loading.ts
Normal file
80
packages/server/src/server/agent/agent-loading.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
toAgentPersistenceHandle,
|
||||
} from "../persistence-hooks.js";
|
||||
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
|
||||
export interface EnsureAgentLoadedDeps {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
validProviders?: Iterable<AgentProvider>;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function ensureAgentLoaded(
|
||||
agentId: string,
|
||||
deps: EnsureAgentLoadedDeps,
|
||||
): Promise<ManagedAgent> {
|
||||
const existing = deps.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = pendingAgentInitializations.get(agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const record = await deps.agentStorage.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
|
||||
const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence);
|
||||
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await deps.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
|
||||
} else {
|
||||
const config = buildSessionConfig(record, {
|
||||
validProviders,
|
||||
logger: deps.logger,
|
||||
});
|
||||
if (!config) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
await deps.agentManager.hydrateTimelineFromProvider(agentId);
|
||||
return deps.agentManager.getAgent(agentId) ?? snapshot;
|
||||
})();
|
||||
|
||||
pendingAgentInitializations.set(agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentInitializations.get(agentId);
|
||||
if (current === initPromise) {
|
||||
pendingAgentInitializations.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -800,6 +800,12 @@ export class AgentManager {
|
||||
: overrides;
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
@@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
@@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
listen: `127.0.0.1:${disabledPort}`,
|
||||
paseoHome: disabledPaseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
mcpInjectIntoAgents: false,
|
||||
staticDir: disabledStaticDir,
|
||||
@@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { JsonValue } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js";
|
||||
|
||||
export type { ManagedAgent };
|
||||
|
||||
@@ -91,7 +94,7 @@ export function toAgentPayload(
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
runtimeInfo,
|
||||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
@@ -128,6 +131,93 @@ export function toAgentPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||
logger: Logger,
|
||||
): AgentSnapshotPayload {
|
||||
const defaultCapabilities = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
} as const;
|
||||
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record));
|
||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||
|
||||
const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id);
|
||||
const runtimeInfo = record.runtimeInfo
|
||||
? {
|
||||
provider: coerceAgentProvider(
|
||||
logger,
|
||||
providerRegistry,
|
||||
record.runtimeInfo.provider,
|
||||
record.id,
|
||||
),
|
||||
sessionId: record.runtimeInfo.sessionId,
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
|
||||
? { model: record.runtimeInfo.model ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
|
||||
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
|
||||
? { modeId: record.runtimeInfo.modeId ?? null }
|
||||
: {}),
|
||||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
provider,
|
||||
cwd: record.cwd,
|
||||
model: record.config?.model ?? null,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||
runtimeInfo,
|
||||
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
}),
|
||||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||
status: record.lastStatus,
|
||||
capabilities: defaultCapabilities,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence),
|
||||
title: record.title ?? record.config?.title ?? null,
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
labels: record.labels,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
|
||||
const timestamps = [record.updatedAt, record.lastActivityAt]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map((value) => ({
|
||||
raw: value,
|
||||
parsed: Date.parse(value),
|
||||
}))
|
||||
.filter((value) => !Number.isNaN(value.parsed));
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
return record.updatedAt;
|
||||
}
|
||||
|
||||
timestamps.sort((a, b) => b.parsed - a.parsed);
|
||||
return timestamps[0].raw;
|
||||
}
|
||||
|
||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||
|
||||
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
logger,
|
||||
});
|
||||
|
||||
let allowedHosts: string[] | undefined;
|
||||
let mcpAllowedHosts: string[] | undefined;
|
||||
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
agentMcpTransports.delete(sessionId);
|
||||
},
|
||||
enableDnsRebindingProtection: true,
|
||||
...(allowedHosts ? { allowedHosts } : {}),
|
||||
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
});
|
||||
});
|
||||
|
||||
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||
const url = `http://127.0.0.1:${port}/mcp/agents`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { AgentSnapshotPayloadSchema } from "../../shared/messages.js";
|
||||
|
||||
type TestDeps = {
|
||||
agentManager: AgentManager;
|
||||
@@ -29,10 +31,17 @@ function createTestDeps(): TestDeps {
|
||||
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
|
||||
notifyAgentState: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
listAgents: vi.fn().mockReturnValue([]),
|
||||
getTimeline: vi.fn().mockReturnValue([]),
|
||||
resumeAgentFromPersistence: vi.fn(),
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
respondToPermission: vi.fn(),
|
||||
cancelAgentRun: vi.fn(),
|
||||
getPendingPermissions: vi.fn(),
|
||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||
};
|
||||
|
||||
const agentStorageSpies = {
|
||||
@@ -40,7 +49,7 @@ function createTestDeps(): TestDeps {
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
applySnapshot: vi.fn(),
|
||||
list: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -68,6 +77,43 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): Provi
|
||||
};
|
||||
}
|
||||
|
||||
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredAgentRecord {
|
||||
const now = "2026-04-11T00:00:00.000Z";
|
||||
return {
|
||||
id: "stored-agent",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/stored-project",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
lastUserMessageAt: null,
|
||||
title: "Stored agent",
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
lastModeId: "default",
|
||||
config: {
|
||||
modeId: "default",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
},
|
||||
runtimeInfo: {
|
||||
provider: "claude",
|
||||
sessionId: "session-123",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
},
|
||||
features: [],
|
||||
persistence: {
|
||||
provider: "claude",
|
||||
sessionId: "session-123",
|
||||
},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
internal: false,
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("create_agent MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
@@ -483,4 +529,276 @@ describe("agent snapshot MCP serialization", () => {
|
||||
});
|
||||
expect(Array.isArray(structured.agents[0].features)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns archived agent snapshots from storage for get_agent_status", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const record = createStoredRecord({
|
||||
id: "archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
});
|
||||
spies.agentManager.getAgent.mockReturnValue(null);
|
||||
spies.agentStorage.get.mockResolvedValue(record);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||
const response = await tool.callback({ agentId: "archived-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
status: "closed",
|
||||
snapshot: expect.objectContaining({
|
||||
id: "archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
title: "Stored agent",
|
||||
status: "closed",
|
||||
}),
|
||||
});
|
||||
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
|
||||
});
|
||||
|
||||
it("does not expose internal stored agents from get_agent_status", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue(null);
|
||||
spies.agentStorage.get.mockResolvedValue(
|
||||
createStoredRecord({
|
||||
id: "internal-agent",
|
||||
internal: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_status"];
|
||||
|
||||
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
"Agent internal-agent not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes stored non-archived agents in list_agents by default", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const liveAgent = {
|
||||
id: "live-agent",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/live-project",
|
||||
config: {},
|
||||
runtimeInfo: undefined,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lifecycle: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
features: [],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
attention: { requiresAttention: false },
|
||||
} as unknown as ManagedAgent;
|
||||
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "closed-agent", archivedAt: null }),
|
||||
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||
createStoredRecord({ id: "live-agent", archivedAt: null }),
|
||||
createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent.agents).toEqual([
|
||||
expect.objectContaining({ id: "live-agent" }),
|
||||
expect.objectContaining({ id: "closed-agent", archivedAt: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes archived stored agents in list_agents when requested", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const liveAgent = {
|
||||
id: "live-agent",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/live-project",
|
||||
config: {},
|
||||
runtimeInfo: undefined,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lifecycle: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
features: [],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
attention: { requiresAttention: false },
|
||||
} as unknown as ManagedAgent;
|
||||
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||
createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||
createStoredRecord({
|
||||
id: "internal-archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
internal: true,
|
||||
}),
|
||||
createStoredRecord({ id: "not-archived-agent", archivedAt: null }),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
|
||||
expect(response.structuredContent.agents).toEqual([
|
||||
expect.objectContaining({ id: "live-agent" }),
|
||||
expect.objectContaining({
|
||||
id: "archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "not-archived-agent",
|
||||
archivedAt: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits list_agents payloads that satisfy the declared output schema", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const liveAgent = {
|
||||
id: "live-agent",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/live-project",
|
||||
config: {},
|
||||
runtimeInfo: undefined,
|
||||
createdAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lifecycle: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
features: [],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
attention: { requiresAttention: false },
|
||||
} as unknown as ManagedAgent;
|
||||
spies.agentManager.listAgents.mockReturnValue([liveAgent]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "stored-non-archived", archivedAt: null }),
|
||||
createStoredRecord({ id: "stored-archived", archivedAt: "2026-04-12T00:00:00.000Z" }),
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_agents"];
|
||||
const response = await tool.callback({ includeArchived: true });
|
||||
|
||||
const parsed = z.array(AgentSnapshotPayloadSchema).safeParse(response.structuredContent.agents);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`list_agents response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("loads archived agents before reading get_agent_activity", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const record = createStoredRecord({ id: "archived-activity-agent" });
|
||||
const snapshot = {
|
||||
id: "archived-activity-agent",
|
||||
currentModeId: "default",
|
||||
} as ManagedAgent;
|
||||
spies.agentManager.getAgent
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValue(snapshot)
|
||||
.mockReturnValue(snapshot);
|
||||
spies.agentStorage.get.mockResolvedValue(record);
|
||||
spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot);
|
||||
spies.agentManager.getTimeline.mockReturnValue([
|
||||
{
|
||||
kind: "status",
|
||||
timestamp: "2026-04-11T00:00:00.000Z",
|
||||
text: "Agent resumed",
|
||||
},
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as any,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["get_agent_activity"];
|
||||
const response = await tool.callback({ agentId: "archived-activity-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "archived-activity-agent",
|
||||
updateCount: 1,
|
||||
currentModeId: "default",
|
||||
}),
|
||||
);
|
||||
expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled();
|
||||
expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(
|
||||
"archived-activity-agent",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
AgentPermissionResponseSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "../messages.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
@@ -198,6 +200,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const requireProviderRegistry = (): Record<AgentProvider, ProviderDefinition> => {
|
||||
if (!providerRegistry) {
|
||||
throw new Error("Provider registry is required to load stored agent records");
|
||||
}
|
||||
return providerRegistry;
|
||||
};
|
||||
|
||||
const resolveCallerAgent = () => {
|
||||
if (!callerAgentId) {
|
||||
return null;
|
||||
@@ -594,6 +603,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (notifyOnFinish && callerAgentId) {
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: snapshot.id,
|
||||
callerAgentId,
|
||||
logger: childLogger,
|
||||
@@ -759,33 +769,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
},
|
||||
async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
if (agentManager.hasInFlightRun(agentId)) {
|
||||
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
|
||||
}
|
||||
|
||||
if (sessionMode) {
|
||||
await agentManager.setAgentMode(agentId, sessionMode);
|
||||
}
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(agentId, prompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error({ err: error, agentId }, "Failed to record user message");
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt, childLogger, {
|
||||
replaceRunning: true,
|
||||
await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId,
|
||||
userMessageText: prompt,
|
||||
prompt,
|
||||
sessionMode,
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
if (notifyOnFinish && callerAgentId) {
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
logger: childLogger,
|
||||
@@ -849,19 +850,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
if (snapshot) {
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentStorage,
|
||||
snapshot,
|
||||
childLogger,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
status: snapshot.lifecycle,
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const record = await agentStorage.get(agentId);
|
||||
if (!record || record.internal) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentStorage,
|
||||
snapshot,
|
||||
const structuredSnapshot = buildStoredAgentPayload(
|
||||
record,
|
||||
requireProviderRegistry(),
|
||||
childLogger,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
status: snapshot.lifecycle,
|
||||
status: structuredSnapshot.status,
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
@@ -873,21 +890,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
{
|
||||
title: "List agents",
|
||||
description: "List all live agents managed by the server.",
|
||||
inputSchema: {},
|
||||
inputSchema: {
|
||||
includeArchived: z.boolean().optional().default(false),
|
||||
},
|
||||
outputSchema: {
|
||||
agents: z.array(AgentSnapshotPayloadSchema),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const snapshots = agentManager.listAgents();
|
||||
const agents = await Promise.all(
|
||||
snapshots.map((snapshot) =>
|
||||
async ({ includeArchived }) => {
|
||||
const liveSnapshots = agentManager.listAgents();
|
||||
const liveAgents = await Promise.all(
|
||||
liveSnapshots.map((snapshot) =>
|
||||
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
|
||||
),
|
||||
);
|
||||
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
|
||||
const storedRecords = await agentStorage.list();
|
||||
const storedAgents = storedRecords
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id))
|
||||
.filter((record) => includeArchived || !record.archivedAt)
|
||||
.map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger));
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ agents }),
|
||||
structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -1562,6 +1588,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
},
|
||||
async ({ agentId, limit }) => {
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: childLogger,
|
||||
});
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
|
||||
|
||||
78
packages/server/src/server/agent/mcp-shared.test.ts
Normal file
78
packages/server/src/server/agent/mcp-shared.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { setupFinishNotification } from "./mcp-shared.js";
|
||||
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
describe("setupFinishNotification", () => {
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
|
||||
const childAgent = {
|
||||
id: "child-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Child Agent" },
|
||||
} as ManagedAgent;
|
||||
|
||||
const agentManager = {
|
||||
getAgent: vi.fn((agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return {
|
||||
id: "caller-agent",
|
||||
lifecycle: "idle",
|
||||
config: { title: "Caller Agent" },
|
||||
} as ManagedAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
}),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
|
||||
} as unknown as AgentManager;
|
||||
|
||||
const agentStorage = {
|
||||
get: vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
),
|
||||
} as unknown as AgentStorage;
|
||||
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(subscriber).not.toBeNull();
|
||||
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
|
||||
});
|
||||
|
||||
expect((agentManager as any).streamAgent).not.toHaveBeenCalled();
|
||||
expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentPermissionRequest,
|
||||
AgentRunOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
@@ -94,6 +99,7 @@ export function resolveProviderAndModel(params: {
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
runOptions?: AgentRunOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -171,9 +177,10 @@ export function startAgentRun(
|
||||
options?: StartAgentRunOptions,
|
||||
): void {
|
||||
const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));
|
||||
const runOptions = options?.runOptions;
|
||||
const iterator = shouldReplace
|
||||
? agentManager.replaceAgentRun(agentId, prompt)
|
||||
: agentManager.streamAgent(agentId, prompt);
|
||||
? agentManager.replaceAgentRun(agentId, prompt, runOptions)
|
||||
: agentManager.streamAgent(agentId, prompt, runOptions);
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _ of iterator) {
|
||||
@@ -185,20 +192,107 @@ export function startAgentRun(
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the archived flag from a stored agent record.
|
||||
* Shared across Session (app/WS), MCP, and CLI so every surface that acts on
|
||||
* an archived agent unarchives it the same way.
|
||||
*/
|
||||
export async function unarchiveAgentState(
|
||||
agentStorage: AgentStorage,
|
||||
agentManager: AgentManager,
|
||||
agentId: string,
|
||||
): Promise<boolean> {
|
||||
const record = await agentStorage.get(agentId);
|
||||
if (!record || !record.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
const updatedAt = new Date().toISOString();
|
||||
await agentStorage.upsert({
|
||||
...record,
|
||||
archivedAt: null,
|
||||
updatedAt,
|
||||
});
|
||||
agentManager.notifyAgentState(agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentId: string;
|
||||
/** Raw user text to record in the timeline. */
|
||||
userMessageText: string;
|
||||
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
||||
prompt: AgentPromptInput;
|
||||
messageId?: string;
|
||||
runOptions?: AgentRunOptions;
|
||||
/** Optional mode to set on the agent before the run starts. */
|
||||
sessionMode?: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full send-prompt orchestration: unarchive → load → (optional mode change) →
|
||||
* record user message → start run.
|
||||
*
|
||||
* Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP)
|
||||
* MUST go through this so behavior can never drift between them.
|
||||
*/
|
||||
export async function sendPromptToAgent(params: SendPromptToAgentParams): Promise<void> {
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId,
|
||||
userMessageText,
|
||||
prompt,
|
||||
messageId,
|
||||
runOptions,
|
||||
sessionMode,
|
||||
logger,
|
||||
} = params;
|
||||
|
||||
await unarchiveAgentState(agentStorage, agentManager, agentId);
|
||||
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (sessionMode) {
|
||||
await agentManager.setAgentMode(agentId, sessionMode);
|
||||
}
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(agentId, userMessageText, {
|
||||
messageId,
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Failed to record user message");
|
||||
}
|
||||
|
||||
startAgentRun(agentManager, agentId, prompt, logger, {
|
||||
replaceRunning: true,
|
||||
runOptions,
|
||||
});
|
||||
}
|
||||
|
||||
interface SetupFinishNotificationParams {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
childAgentId: string;
|
||||
callerAgentId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
||||
const { agentManager, childAgentId, callerAgentId, logger } = params;
|
||||
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
|
||||
let hasSeenRunning = false;
|
||||
let fired = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function notify(reason: "finished" | "errored" | "needs permission"): void {
|
||||
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> {
|
||||
if (fired) {
|
||||
return;
|
||||
}
|
||||
@@ -209,6 +303,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
return;
|
||||
}
|
||||
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
|
||||
const prompt = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ProviderRuntimeSettingsSchema = z
|
||||
.object({
|
||||
command: ProviderCommandSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -64,6 +65,7 @@ export const ProviderOverrideSchema = z
|
||||
command: z.array(z.string().min(1)).min(1).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
models: z.array(ProviderProfileModelSchema).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
order: z.number().optional(),
|
||||
})
|
||||
|
||||
@@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => {
|
||||
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
|
||||
test("disallowedTools flows through to runtime settings", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
disallowedTools: ["WebSearch", "WebFetch"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockState.constructorArgs.claude[0]).toEqual({
|
||||
runtimeSettings: {
|
||||
command: undefined,
|
||||
env: undefined,
|
||||
disallowedTools: ["WebSearch", "WebFetch"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("derived provider inherits and merges disallowedTools from base", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
disallowedTools: ["WebSearch"],
|
||||
},
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
disallowedTools: ["ComputerUse"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const zaiArgs = mockState.constructorArgs.claude.find(
|
||||
(entry) =>
|
||||
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
|
||||
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
|
||||
"ComputerUse",
|
||||
),
|
||||
);
|
||||
expect(zaiArgs).toBeDefined();
|
||||
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
|
||||
"WebSearch",
|
||||
"ComputerUse",
|
||||
]);
|
||||
});
|
||||
|
||||
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
|
||||
@@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
}
|
||||
|
||||
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
|
||||
if (!override?.command && !override?.env) {
|
||||
if (!override?.command && !override?.env && !override?.disallowedTools) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings
|
||||
}
|
||||
: undefined,
|
||||
env: override.env,
|
||||
disallowedTools: override.disallowedTools,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +123,10 @@ function mergeRuntimeSettings(
|
||||
...(override?.env ?? {}),
|
||||
}
|
||||
: undefined,
|
||||
disallowedTools:
|
||||
base?.disallowedTools || override?.disallowedTools
|
||||
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
|
||||
} as const;
|
||||
|
||||
describe("ProviderSnapshotManager", () => {
|
||||
const ttlMs = 5 * 60 * 1_000;
|
||||
const projectCwd = resolve("/tmp/project");
|
||||
const projectACwd = resolve("/tmp/project-a");
|
||||
const projectBCwd = resolve("/tmp/project-b");
|
||||
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh with providers only re-fetches matching providers", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const claudeFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: claudeFetchModels,
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet-4",
|
||||
);
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [createModel("claude", "sonnet-4")],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh treats an empty providers list as a full refresh", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const claudeFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: claudeFetchModels,
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: [] });
|
||||
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
label: "claude",
|
||||
description: "claude test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet-4.5",
|
||||
);
|
||||
});
|
||||
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh ignores provider filters that are not in the registry", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: codexFetchModels,
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.1")],
|
||||
});
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModes = deferred<AgentMode[]>();
|
||||
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
const changes: ProviderSnapshotEntry[][] = [];
|
||||
manager.on("change", (entries) => changes.push(entries));
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
|
||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||
{
|
||||
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh(projectCwd);
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
manager.refresh({ cwd: projectCwd });
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
||||
let now = 1_000;
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.1")],
|
||||
modes: [createMode("auto")],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
|
||||
let now = 2_000;
|
||||
const staleRefreshModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockImplementationOnce(async () => staleRefreshModels.promise);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
|
||||
let now = 3_000;
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => [createModel("codex", "gpt-5.1")],
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
now += ttlMs - 1;
|
||||
|
||||
const freshSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
|
||||
let now = 4_000;
|
||||
const unavailableFetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
|
||||
const unavailableIsAvailable = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
const errorFetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockRejectedValueOnce(new Error("model lookup failed"))
|
||||
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
isAvailable: unavailableIsAvailable,
|
||||
fetchModels: async (cwd) => unavailableFetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async (cwd) => errorFetchModels(cwd),
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
|
||||
});
|
||||
|
||||
now += ttlMs + 1;
|
||||
|
||||
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||
|
||||
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
|
||||
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||
"sonnet",
|
||||
);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("getSnapshot respects an injected TTL", async () => {
|
||||
let now = 5_000;
|
||||
const customTtlMs = 100;
|
||||
const fetchModels = vi
|
||||
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => fetchModels(cwd),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||
ttlMs: customTtlMs,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
now += customTtlMs - 1;
|
||||
manager.getSnapshot(projectCwd);
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 2;
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
|
||||
@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
const DEFAULT_CWD_KEY = "__default__";
|
||||
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||
|
||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
||||
type ProviderSnapshotManagerOptions = {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
type ProviderSnapshotRefreshOptions = {
|
||||
cwd?: string;
|
||||
providers?: AgentProvider[];
|
||||
};
|
||||
|
||||
export class ProviderSnapshotManager {
|
||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||
private readonly lastCheckedAts = new Map<string, number>();
|
||||
private readonly warmUps = new Map<string, Promise<void>>();
|
||||
private readonly events = new EventEmitter();
|
||||
private destroyed = false;
|
||||
private readonly ttlMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
options: ProviderSnapshotManagerOptions = {},
|
||||
) {
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
|
||||
void this.warmUp(cwd);
|
||||
return entriesToArray(loadingEntries);
|
||||
}
|
||||
if (this.shouldRevalidate(cwdKey)) {
|
||||
void this.warmUp(cwd);
|
||||
}
|
||||
return entriesToArray(entries);
|
||||
}
|
||||
|
||||
refresh(cwd?: string): void {
|
||||
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
||||
const { cwd } = options;
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
this.resetSnapshotToLoading(cwdKey);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(cwdKey, providers);
|
||||
this.emitChange(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
await this.warmUp(cwd, providers);
|
||||
}
|
||||
|
||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
|
||||
this.destroyed = true;
|
||||
this.events.removeAllListeners();
|
||||
this.snapshots.clear();
|
||||
this.lastCheckedAts.clear();
|
||||
this.warmUps.clear();
|
||||
}
|
||||
|
||||
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async warmUp(cwd?: string): Promise<void> {
|
||||
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const inFlight = this.warmUps.get(cwdKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
const warmUpPromise = Promise.allSettled(
|
||||
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||
).then(() => undefined);
|
||||
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||
).then(() => {
|
||||
if (!providers) {
|
||||
this.lastCheckedAts.set(cwdKey, this.now());
|
||||
}
|
||||
});
|
||||
|
||||
this.warmUps.set(cwdKey, warmUpPromise);
|
||||
|
||||
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "loading",
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = definition.createClient(this.logger);
|
||||
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
|
||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
||||
}
|
||||
|
||||
private shouldRevalidate(cwdKey: string): boolean {
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
return false;
|
||||
}
|
||||
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||
if (lastCheckedAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
return this.now() - lastCheckedAt > this.ttlMs;
|
||||
}
|
||||
|
||||
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const existing = this.snapshots.get(cwdKey);
|
||||
if (existing) {
|
||||
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
|
||||
return created;
|
||||
}
|
||||
|
||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
private resetSnapshotToLoading(
|
||||
cwdKey: string,
|
||||
providers?: AgentProvider[],
|
||||
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
||||
snapshot.set(provider, entry);
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
|
||||
if (!providers) {
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of loadingEntries) {
|
||||
snapshot.set(provider, entry);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
const loadingEntry = loadingEntries.get(provider);
|
||||
if (!loadingEntry) continue;
|
||||
const existing = snapshot.get(provider);
|
||||
snapshot.set(provider, {
|
||||
...loadingEntry,
|
||||
models: existing?.models,
|
||||
modes: existing?.modes,
|
||||
fetchedAt: existing?.fetchedAt,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
|
||||
private getProviderIds(): AgentProvider[] {
|
||||
return Object.keys(this.providerRegistry) as AgentProvider[];
|
||||
}
|
||||
|
||||
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
|
||||
if (!providers || providers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerIds = new Set(this.getProviderIds());
|
||||
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCwdKey(cwd?: string): string {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import {
|
||||
query,
|
||||
type Options,
|
||||
type Query,
|
||||
type SpawnOptions as ClaudeSpawnOptions,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
function createQueryMock(events: unknown[]): Query {
|
||||
let index = 0;
|
||||
return {
|
||||
next: vi.fn(async () =>
|
||||
index < events.length
|
||||
? { done: false, value: events[index++] }
|
||||
: { done: true, value: undefined },
|
||||
),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function createChildProcessStub(): ChildProcess {
|
||||
return {
|
||||
stderr: new EventEmitter(),
|
||||
} as ChildProcess;
|
||||
}
|
||||
|
||||
describe("Claude spawn override", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("bypasses the shell when spawning Claude Code", async () => {
|
||||
let capturedOptions: Options | undefined;
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
capturedOptions = options;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "claude-spawn-shell-regression-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
const spawnSpy = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
try {
|
||||
await session.run("spawn shell regression");
|
||||
capturedOptions?.spawnClaudeCodeProcess?.({
|
||||
command: "node",
|
||||
args: ["claude.js", "--mcp-config", '{"mcpServers":{"paseo":{"type":"http"}}}'],
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
signal: new AbortController().signal,
|
||||
} satisfies ClaudeSpawnOptions);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||
const spawnOptions = spawnSpy.mock.calls[0]?.[2];
|
||||
expect(spawnOptions?.shell).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -340,6 +340,8 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
const models = await client.listModels();
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
|
||||
@@ -171,7 +171,7 @@ type ClaudeAgentSessionOptions = {
|
||||
queryFactory?: typeof query;
|
||||
};
|
||||
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "max";
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
function resolveClaudeSpawnCommand(
|
||||
spawnOptions: SpawnOptions,
|
||||
@@ -238,7 +238,13 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
}
|
||||
|
||||
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "max";
|
||||
return (
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh" ||
|
||||
value === "max"
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeClaudeProjectPath(cwd: string): string {
|
||||
@@ -1134,6 +1140,8 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
if (command?.mode === "replace") {
|
||||
return await isCommandAvailable(command.argv[0]);
|
||||
}
|
||||
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
|
||||
// via process.execPath. No external `claude` binary is required.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1142,6 +1150,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
const resolvedBinary = (await findExecutable("claude")) ?? "not found";
|
||||
const available = await this.isAvailable();
|
||||
const version = await resolveClaudeVersion(this.runtimeSettings);
|
||||
const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null;
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
@@ -1162,6 +1171,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||
{ label: "Binary", value: resolvedBinary },
|
||||
...(version ? [{ label: "Version", value: version }] : []),
|
||||
...(auth ? [{ label: "Auth", value: auth }] : []),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
@@ -1208,6 +1218,48 @@ async function resolveClaudeVersion(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveClaudeAuth(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Promise<string | null> {
|
||||
const command = runtimeSettings?.command;
|
||||
|
||||
const run = async (
|
||||
executable: string,
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
try {
|
||||
return await execCommand(executable, args, { timeout: 5_000 });
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; message?: string };
|
||||
return {
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? err.message ?? "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let result: { stdout: string; stderr: string };
|
||||
if (command?.mode === "replace") {
|
||||
result = await run(command.argv[0]!, [...command.argv.slice(1), "auth", "status"]);
|
||||
} else {
|
||||
const executable = await findExecutable("claude");
|
||||
if (!executable) {
|
||||
return null;
|
||||
}
|
||||
result = await run(executable, ["auth", "status"]);
|
||||
}
|
||||
|
||||
const combined = [result.stdout, result.stderr]
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.join("\n");
|
||||
return combined || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractContextWindowSize(modelUsage: unknown): number | undefined {
|
||||
if (!modelUsage || typeof modelUsage !== "object") {
|
||||
return undefined;
|
||||
@@ -2068,7 +2120,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
let effort: ClaudeOptions["effort"];
|
||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||
thinking = { type: "adaptive" };
|
||||
effort = thinkingOptionId;
|
||||
// SDK 0.2.71 types `effort` as 'low' | 'medium' | 'high' | 'max'; Opus 4.7
|
||||
// adds 'xhigh' which the binary accepts but the typings don't yet expose.
|
||||
effort = thinkingOptionId as ClaudeOptions["effort"];
|
||||
}
|
||||
|
||||
const appendedSystemPrompt = [
|
||||
@@ -2145,6 +2199,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.claudeSessionId) {
|
||||
base.resume = this.claudeSessionId;
|
||||
}
|
||||
if (this.runtimeSettings?.disallowedTools?.length) {
|
||||
base.disallowedTools = [
|
||||
...(base.disallowedTools ?? []),
|
||||
...this.runtimeSettings.disallowedTools,
|
||||
];
|
||||
}
|
||||
return this.applyRuntimeSettings(base);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
|
||||
@@ -7,7 +7,29 @@ const CLAUDE_THINKING_OPTIONS = [
|
||||
{ id: "max", label: "Max" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_OPUS_4_7_THINKING_OPTIONS = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "xhigh", label: "Extra High" },
|
||||
{ id: "max", label: "Max" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-7[1m]",
|
||||
label: "Opus 4.7 1M",
|
||||
description: "Opus 4.7 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_OPUS_4_7_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-7",
|
||||
label: "Opus 4.7",
|
||||
description: "Opus 4.7 · Latest release",
|
||||
thinkingOptions: [...CLAUDE_OPUS_4_7_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6[1m]",
|
||||
|
||||
@@ -4205,7 +4205,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
if (command?.mode === "replace") {
|
||||
return await isCommandAvailable(command.argv[0]);
|
||||
}
|
||||
return true;
|
||||
return await isCommandAvailable("codex");
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
superConstructorOptions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("./acp-agent.js", () => ({
|
||||
ACPAgentClient: class ACPAgentClient {
|
||||
readonly provider: string;
|
||||
|
||||
constructor(options: unknown) {
|
||||
this.provider = "acp";
|
||||
mockState.superConstructorOptions.push(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient", () => {
|
||||
test("passes the custom command only as defaultCommand", () => {
|
||||
new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: ["hermes", "acp"],
|
||||
env: {
|
||||
HERMES_LOG: "info",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockState.superConstructorOptions).toEqual([
|
||||
{
|
||||
provider: "acp",
|
||||
logger: expect.any(Object),
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
HERMES_LOG: "info",
|
||||
},
|
||||
},
|
||||
defaultCommand: ["hermes", "acp"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
provider: "acp",
|
||||
logger: options.logger,
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: options.command,
|
||||
},
|
||||
env: options.env,
|
||||
},
|
||||
defaultCommand: options.command as [string, ...string[]],
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
await idleEventGate.promise;
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
await Promise.resolve();
|
||||
idleEventGate.resolve();
|
||||
|
||||
await expect(runPromise).resolves.toMatchObject({
|
||||
sessionId: "session-1",
|
||||
finalText: "",
|
||||
timeline: [],
|
||||
usage: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,65 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
});
|
||||
|
||||
describe("OpenCode adapter context-window normalization", () => {
|
||||
test("close reconciliation aborts then archives upstream session", async () => {
|
||||
const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
|
||||
const update = vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1", time: { archived: Date.now() } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||
client: {
|
||||
session: {
|
||||
abort,
|
||||
update,
|
||||
},
|
||||
} as never,
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/project",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(abort).toHaveBeenCalledWith({
|
||||
sessionID: "session-1",
|
||||
directory: "/tmp/project",
|
||||
});
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
sessionID: "session-1",
|
||||
directory: "/tmp/project",
|
||||
time: {
|
||||
archived: expect.any(Number),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("close reconciliation still archives when abort returns an error", async () => {
|
||||
const abort = vi.fn().mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { data: {}, errors: [], success: false },
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1", time: { archived: Date.now() } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
await __openCodeInternals.reconcileOpenCodeSessionClose({
|
||||
client: {
|
||||
session: {
|
||||
abort,
|
||||
update,
|
||||
},
|
||||
} as never,
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/project",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||
expect(
|
||||
__openCodeInternals.buildOpenCodePromptParts([
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
@@ -107,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
||||
"does not exist",
|
||||
"unsupported model",
|
||||
] as const;
|
||||
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
||||
"headers timeout",
|
||||
"headers timeout error",
|
||||
"headers_timeout",
|
||||
"und_err_headers_timeout",
|
||||
] as const;
|
||||
|
||||
const OpencodeToolStateSchema = z
|
||||
.object({
|
||||
@@ -231,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string {
|
||||
return normalized.length > 0 ? normalized : "Unknown error";
|
||||
}
|
||||
|
||||
function isOpenCodeNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
(error as { name?: unknown }).name === "NotFoundError"
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileOpenCodeSessionClose(params: {
|
||||
client: Pick<OpencodeClient, "session">;
|
||||
sessionId: string;
|
||||
directory: string;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
const { client, sessionId, directory, logger } = params;
|
||||
|
||||
try {
|
||||
const response = await client.session.abort({
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
});
|
||||
if (response.error && !isOpenCodeNotFoundError(response.error)) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(response.error),
|
||||
},
|
||||
"Failed to abort OpenCode session during close",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(error),
|
||||
},
|
||||
"Failed to abort OpenCode session during close",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.session.update({
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
time: { archived: Date.now() },
|
||||
});
|
||||
if (response.error && !isOpenCodeNotFoundError(response.error)) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(response.error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
error: normalizeTurnFailureError(error),
|
||||
},
|
||||
"Failed to archive OpenCode session during close",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
|
||||
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
|
||||
if (!normalized) {
|
||||
@@ -239,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
|
||||
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
const diagnostics = new Set<string>();
|
||||
const queue: unknown[] = [error];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = stringifyUnknownError(current).trim().toLowerCase();
|
||||
if (normalized) {
|
||||
diagnostics.add(normalized);
|
||||
}
|
||||
|
||||
if (typeof current === "object") {
|
||||
const record = current as {
|
||||
message?: unknown;
|
||||
code?: unknown;
|
||||
name?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
for (const value of [record.message, record.code, record.name]) {
|
||||
if (typeof value === "string") {
|
||||
const diagnostic = value.trim().toLowerCase();
|
||||
if (diagnostic) {
|
||||
diagnostics.add(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.cause) {
|
||||
queue.push(record.cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...diagnostics].some((diagnostic) =>
|
||||
OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)),
|
||||
);
|
||||
}
|
||||
|
||||
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
@@ -570,6 +687,7 @@ export const __openCodeInternals = {
|
||||
hasNormalizedOpenCodeUsage,
|
||||
mergeOpenCodeStepFinishUsage,
|
||||
parseOpenCodeModelLookupKey,
|
||||
reconcileOpenCodeSessionClose,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
};
|
||||
@@ -918,7 +1036,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
if (command?.mode === "replace") {
|
||||
return await isCommandAvailable(command.argv[0]);
|
||||
}
|
||||
return true;
|
||||
return await isCommandAvailable("opencode");
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
@@ -1042,6 +1160,150 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function mapOpenCodeTodosToTimelineItems(
|
||||
todos: Array<{ content?: string | null; status?: string | null }>,
|
||||
): Extract<AgentTimelineItem, { type: "todo" }> {
|
||||
return {
|
||||
type: "todo",
|
||||
items: todos.flatMap((todo) => {
|
||||
const text = readNonEmptyString(todo.content);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text,
|
||||
completed: todo.status === "completed",
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactionTimelineItem(
|
||||
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
|
||||
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
|
||||
): Extract<AgentTimelineItem, { type: "compaction" }> {
|
||||
return {
|
||||
type: "compaction",
|
||||
status,
|
||||
...(trigger ? { trigger } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const;
|
||||
const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const;
|
||||
const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const;
|
||||
const PERMISSION_TITLE_BY_NAME: Record<string, string> = {
|
||||
external_directory: "Access external directory",
|
||||
bash: "Run shell command",
|
||||
read: "Read files",
|
||||
read_file: "Read files",
|
||||
write: "Write files",
|
||||
write_file: "Write files",
|
||||
create_file: "Write files",
|
||||
edit: "Edit files",
|
||||
apply_patch: "Edit files",
|
||||
apply_diff: "Edit files",
|
||||
};
|
||||
|
||||
function toHumanReadablePermissionTitle(permission: string): string {
|
||||
const mapped = PERMISSION_TITLE_BY_NAME[permission];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const normalized = permission
|
||||
.split(/[\s_-]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
return normalized.length > 0 ? normalized : "Permission request";
|
||||
}
|
||||
|
||||
function readFirstStringFromRecord(
|
||||
record: Record<string, unknown> | null,
|
||||
keys: readonly string[],
|
||||
): string | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = readNonEmptyString(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPermissionField(
|
||||
metadata: Record<string, unknown> | null,
|
||||
keys: readonly string[],
|
||||
): string | null {
|
||||
const direct = readFirstStringFromRecord(metadata, keys);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const nestedInput = readOpenCodeRecord(metadata?.input);
|
||||
return readFirstStringFromRecord(nestedInput, keys);
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionInput(params: {
|
||||
patterns: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
tool: Record<string, unknown> | null;
|
||||
command: string | null;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
...(params.patterns.length > 0 ? { patterns: params.patterns } : {}),
|
||||
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||
...(params.tool ? { tool: params.tool } : {}),
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionDetail(params: {
|
||||
permission: string;
|
||||
input: Record<string, unknown>;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
}): ToolCallDetail {
|
||||
if (params.command) {
|
||||
return {
|
||||
type: "shell",
|
||||
command: params.command,
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: {
|
||||
permission: params.permission,
|
||||
...params.input,
|
||||
},
|
||||
output: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionDescription(params: {
|
||||
reason: string | null;
|
||||
patterns: string[];
|
||||
}): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (params.reason) {
|
||||
parts.push(params.reason);
|
||||
}
|
||||
if (params.patterns.length > 0) {
|
||||
parts.push(`Scope: ${params.patterns.join(", ")}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" - ") : undefined;
|
||||
}
|
||||
|
||||
export function translateOpenCodeEvent(
|
||||
event: OpenCodeEvent,
|
||||
state: OpenCodeEventTranslationState,
|
||||
@@ -1141,6 +1403,12 @@ export function translateOpenCodeEvent(
|
||||
item: parsedToolPart.data,
|
||||
});
|
||||
}
|
||||
} else if (part.type === "compaction") {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"),
|
||||
});
|
||||
} else if (part.type === "step-finish") {
|
||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
|
||||
@@ -1198,6 +1466,31 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||
const tool = readOpenCodeRecord(event.properties.tool);
|
||||
const patterns = Array.isArray(event.properties.patterns)
|
||||
? event.properties.patterns.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
|
||||
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
|
||||
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
|
||||
const input = buildOpenCodePermissionInput({
|
||||
patterns,
|
||||
metadata,
|
||||
tool,
|
||||
command,
|
||||
});
|
||||
const detail = buildOpenCodePermissionDetail({
|
||||
permission: event.properties.permission,
|
||||
input,
|
||||
command,
|
||||
cwd,
|
||||
});
|
||||
const description = buildOpenCodePermissionDescription({
|
||||
reason,
|
||||
patterns,
|
||||
});
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
@@ -1206,9 +1499,10 @@ export function translateOpenCodeEvent(
|
||||
provider: "opencode",
|
||||
name: event.properties.permission,
|
||||
kind: "tool",
|
||||
title: event.properties.permission,
|
||||
description: event.properties.patterns?.join(", "),
|
||||
input: event.properties.metadata,
|
||||
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||
...(description ? { description } : {}),
|
||||
input,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
break;
|
||||
@@ -1261,6 +1555,32 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "todo.updated": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: mapOpenCodeTodosToTimelineItems(event.properties.todos),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.compacted": {
|
||||
if (event.properties.sessionID !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: createCompactionTimelineItem("completed"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "session.idle": {
|
||||
if (event.properties.sessionID === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
@@ -1530,6 +1850,17 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
if (isOpenCodeHeadersTimeoutFailure(response.error)) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: response.error,
|
||||
commandName: slashCommand.commandName,
|
||||
turnId,
|
||||
},
|
||||
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const errorMsg = normalizeTurnFailureError(response.error);
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
||||
@@ -1543,6 +1874,17 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isOpenCodeHeadersTimeoutFailure(err)) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err,
|
||||
commandName: slashCommand.commandName,
|
||||
turnId,
|
||||
},
|
||||
"OpenCode slash command hit a header timeout; waiting for SSE terminal event",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
||||
turnId,
|
||||
@@ -1932,6 +2274,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.abortController?.abort();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/home/user/secrets/*"],
|
||||
metadata: {
|
||||
command: "ls /home/user/secrets",
|
||||
reason: "Need to inspect generated files",
|
||||
},
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
description: "Need to inspect generated files - Scope: /home/user/secrets/*",
|
||||
input: {
|
||||
patterns: ["/home/user/secrets/*"],
|
||||
metadata: {
|
||||
command: "ls /home/user/secrets",
|
||||
reason: "Need to inspect generated files",
|
||||
},
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
command: "ls /home/user/secrets",
|
||||
},
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls /home/user/secrets",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to unknown permission detail when command metadata is absent", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-2",
|
||||
sessionID: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-2",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
description: "Need to access temporary checkout - Scope: /tmp/outside/*",
|
||||
input: {
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits usage_updated after step-finish parts", () => {
|
||||
const state = createState();
|
||||
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
||||
@@ -310,6 +418,96 @@ describe("translateOpenCodeEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits normalized todo timeline items from todo.updated", () => {
|
||||
const state = createState();
|
||||
|
||||
const events = translateOpenCodeEvent(
|
||||
{
|
||||
type: "todo.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
todos: [
|
||||
{ content: "Outline", status: "pending", priority: "high" },
|
||||
{ content: "Ship", status: "completed", priority: "medium" },
|
||||
{ content: " ", status: "completed", priority: "low" },
|
||||
],
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "Outline", completed: false },
|
||||
{ text: "Ship", completed: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits compaction loading timeline items from compaction parts", () => {
|
||||
const state = createState();
|
||||
|
||||
const events = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "compaction-part-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "message-compaction-1",
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "auto",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits compaction completed timeline items from session.compacted", () => {
|
||||
const state = createState();
|
||||
|
||||
const events = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.compacted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits reasoning from message.part.delta events", () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -510,6 +708,99 @@ describe("translateOpenCodeEvent", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits turn_completed from session.status idle", () => {
|
||||
const state = createState();
|
||||
state.streamedPartKeys.add("text:part-1");
|
||||
state.partTypes.set("part-1", "text");
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
usage: undefined,
|
||||
},
|
||||
]);
|
||||
expect(state.streamedPartKeys.size).toBe(0);
|
||||
expect(state.partTypes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits turn_failed from fatal session.status retry", () => {
|
||||
const state = createState();
|
||||
state.streamedPartKeys.add("text:part-1");
|
||||
state.partTypes.set("part-1", "text");
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "Invalid API key",
|
||||
next: Date.now() + 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: "Invalid API key",
|
||||
},
|
||||
]);
|
||||
expect(state.streamedPartKeys.size).toBe(0);
|
||||
expect(state.partTypes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores transient session.status updates", () => {
|
||||
const state = createState();
|
||||
|
||||
const busy = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: { type: "busy" },
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const retry = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message: "rate limited",
|
||||
next: Date.now() + 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(busy).toEqual([]);
|
||||
expect(retry).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits structured assistant output when schema mode completes without text parts", () => {
|
||||
const state = createState();
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentProvider } from "../agent-sdk-types.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
};
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function isolatePathTo(dir: string): void {
|
||||
process.env.PATH = dir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
}
|
||||
|
||||
function writeProviderShim(dir: string, command: string): string {
|
||||
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
|
||||
const content =
|
||||
process.platform === "win32"
|
||||
? `@echo off\r\necho ${command} 1.0\r\n`
|
||||
: `#!/bin/sh\necho ${command} 1.0\n`;
|
||||
writeFileSync(filePath, content);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("default provider availability", () => {
|
||||
test("Codex reports unavailable when the default command cannot be resolved", async () => {
|
||||
const binDir = makeTempDir("provider-availability-codex-");
|
||||
isolatePathTo(binDir);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
|
||||
const binDir = makeTempDir("provider-availability-claude-");
|
||||
isolatePathTo(binDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
|
||||
const binDir = makeTempDir("provider-availability-opencode-");
|
||||
isolatePathTo(binDir);
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("Codex reports available when the default command resolves from PATH", async () => {
|
||||
const binDir = makeTempDir("provider-availability-codex-");
|
||||
isolatePathTo(binDir);
|
||||
writeProviderShim(binDir, "codex");
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test("OpenCode reports available when the default command resolves from PATH", async () => {
|
||||
const binDir = makeTempDir("provider-availability-opencode-");
|
||||
isolatePathTo(binDir);
|
||||
writeProviderShim(binDir, "opencode");
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||
isolatePathTo(binDir);
|
||||
const workdir = makeTempDir("provider-availability-manager-work-");
|
||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new CodexAppServerAgentClient(createTestLogger()),
|
||||
},
|
||||
registry: storage,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
await expect(manager.listProviderAvailability()).resolves.toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
|
||||
const binDir = makeTempDir("provider-availability-resume-bin-");
|
||||
isolatePathTo(binDir);
|
||||
const workdir = makeTempDir("provider-availability-resume-work-");
|
||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new CodexAppServerAgentClient(createTestLogger()),
|
||||
},
|
||||
registry: storage,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.resumeAgentFromPersistence(
|
||||
{
|
||||
provider: "codex" as AgentProvider,
|
||||
sessionId: "missing-codex-session",
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
},
|
||||
{ cwd: workdir },
|
||||
),
|
||||
).rejects.toThrow("Provider 'codex' is not available");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
type SpawnResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type ProviderLaunchCase = {
|
||||
provider: "claude" | "codex" | "opencode" | "generic-acp";
|
||||
binaryName: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
};
|
||||
|
||||
const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}';
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeFixture(binaryName: string, expectedArgs: string[]) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `));
|
||||
tempDirs.push(root);
|
||||
|
||||
const fakeDaemonNode = path.join(root, "Fake Paseo.exe");
|
||||
copyFileSync(process.execPath, fakeDaemonNode);
|
||||
|
||||
const assertScript = path.join(root, "assert-argv.js");
|
||||
writeFileSync(
|
||||
assertScript,
|
||||
`
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("fake-provider 1.0.0");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
|
||||
const actual = process.argv.slice(2);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
console.error("ARGV_MISMATCH");
|
||||
console.error(JSON.stringify({ expected, actual }));
|
||||
process.exit(42);
|
||||
}
|
||||
console.log("ARGV_OK");
|
||||
`,
|
||||
);
|
||||
|
||||
const shim = path.join(root, `${binaryName}.cmd`);
|
||||
writeFileSync(
|
||||
shim,
|
||||
["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"),
|
||||
);
|
||||
|
||||
return { root, shim, expectedArgs };
|
||||
}
|
||||
|
||||
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
|
||||
return new Promise((resolve) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let settled = false;
|
||||
|
||||
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
...result,
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
settle({ code: null, signal: "SIGKILL" });
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
error = err;
|
||||
settle({ code: null, signal: null });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
settle({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runProviderFixture(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
expectedArgs: string[];
|
||||
shell?: boolean;
|
||||
}): Promise<SpawnResult> {
|
||||
const child = spawnProcess(params.command, params.args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...(params.shell === undefined ? {} : { shell: params.shell }),
|
||||
});
|
||||
return collectChild(child);
|
||||
}
|
||||
|
||||
function withPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
||||
const pathKey =
|
||||
process.platform === "win32"
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
||||
: "PATH";
|
||||
const previousPath = process.env[pathKey];
|
||||
process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir;
|
||||
|
||||
return run().finally(() => {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
} else {
|
||||
process.env[pathKey] = previousPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const providerLaunchCases: ProviderLaunchCase[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
binaryName: "claude",
|
||||
args: ["--mcp-config", JSON_ARG],
|
||||
shell: false,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
binaryName: "codex",
|
||||
args: ["app-server", "--config", JSON_ARG],
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
binaryName: "opencode",
|
||||
args: ["serve", "--port", "49271", "--config", JSON_ARG],
|
||||
},
|
||||
{
|
||||
provider: "generic-acp",
|
||||
binaryName: "generic-acp",
|
||||
args: ["--mcp-config", JSON_ARG],
|
||||
},
|
||||
];
|
||||
|
||||
describe.runIf(process.platform === "win32")("Windows provider launch parity", () => {
|
||||
test("detected claude.cmd can be launched with Claude's JSON-safe spawn shape", async () => {
|
||||
const args = ["--mcp-config", JSON_ARG];
|
||||
const fixture = makeFixture("claude", args);
|
||||
|
||||
await withPathEntry(fixture.root, async () => {
|
||||
const detected = await findExecutable("claude");
|
||||
expect(detected?.toLowerCase()).toBe(fixture.shim.toLowerCase());
|
||||
|
||||
const result = await runProviderFixture({
|
||||
command: detected!,
|
||||
args,
|
||||
expectedArgs: args,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
});
|
||||
|
||||
test.each(
|
||||
providerLaunchCases,
|
||||
)("$provider launches a cmd shim from a path with spaces through spawnProcess", async ({
|
||||
binaryName,
|
||||
args,
|
||||
shell,
|
||||
}) => {
|
||||
const fixture = makeFixture(binaryName, args);
|
||||
|
||||
const result = await runProviderFixture({
|
||||
command: fixture.shim,
|
||||
args: fixture.expectedArgs,
|
||||
expectedArgs: fixture.expectedArgs,
|
||||
shell,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js";
|
||||
|
||||
describe("allowed hosts (vite-style)", () => {
|
||||
it("allows localhost by default", () => {
|
||||
expect(isHostAllowed("localhost:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows subdomains of .localhost by default", () => {
|
||||
expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows IP addresses by default", () => {
|
||||
expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true);
|
||||
expect(isHostAllowed("[::1]:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-default hosts when no allowlist is provided", () => {
|
||||
expect(isHostAllowed("evil.com:6767", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows any host when set to true", () => {
|
||||
expect(isHostAllowed("evil.com:6767", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports leading-dot patterns", () => {
|
||||
const allowed = [".example.com"];
|
||||
expect(isHostAllowed("example.com:6767", allowed)).toBe(true);
|
||||
expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true);
|
||||
expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true);
|
||||
expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
|
||||
expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]);
|
||||
expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses env var values", () => {
|
||||
expect(parseAllowedHostsEnv(undefined)).toBeUndefined();
|
||||
expect(parseAllowedHostsEnv("")).toBeUndefined();
|
||||
expect(parseAllowedHostsEnv("true")).toBe(true);
|
||||
expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
};
|
||||
|
||||
describe("bootstrap provider availability", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads a persisted Codex record without spawning a missing Codex binary", async () => {
|
||||
const { createPaseoDaemon } = await import("./bootstrap.js");
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
||||
tempRoots.push(root);
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
||||
tempRoots.push(binDir);
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
const staticDir = path.join(root, "static");
|
||||
const agentStoragePath = path.join(paseoHome, "agents");
|
||||
const now = new Date("2026-04-16T00:00:00.000Z").toISOString();
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
await mkdir(agentStoragePath, { recursive: true });
|
||||
await mkdir(staticDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(agentStoragePath, `${agentId}.json`),
|
||||
JSON.stringify({
|
||||
id: agentId,
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastStatus: "idle",
|
||||
lastModeId: "auto",
|
||||
config: {
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "codex-session-1",
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentStoragePath,
|
||||
relayEnabled: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
const processFailures: Error[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
processFailures.push(reason instanceof Error ? reason : new Error(String(reason)));
|
||||
};
|
||||
const onUncaughtException = (error: Error) => {
|
||||
processFailures.push(error);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
process.on("uncaughtException", onUncaughtException);
|
||||
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
try {
|
||||
await expect(daemon.agentStorage.list()).resolves.toHaveLength(1);
|
||||
await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({
|
||||
provider: "codex",
|
||||
available: false,
|
||||
error: null,
|
||||
});
|
||||
await expect(
|
||||
ensureAgentLoaded(agentId, {
|
||||
agentManager: daemon.agentManager,
|
||||
agentStorage: daemon.agentStorage,
|
||||
logger: pino({ level: "silent" }),
|
||||
}),
|
||||
).rejects.toThrow("Provider 'codex' is not available");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(processFailures).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
process.off("uncaughtException", onUncaughtException);
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
@@ -163,7 +163,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
listen: socketPath,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -119,7 +119,7 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||
|
||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||
|
||||
@@ -162,7 +162,7 @@ export type PaseoDaemonConfig = {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: AllowedHostsConfig;
|
||||
hostnames?: HostnamesConfig;
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
staticDir: string;
|
||||
@@ -231,7 +231,7 @@ export async function createPaseoDaemon(
|
||||
if (listenTarget.type === "tcp") {
|
||||
app.use((req, res, next) => {
|
||||
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
||||
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
|
||||
if (!isHostnameAllowed(hostHeader, config.hostnames)) {
|
||||
res.status(403).json({ error: "Invalid Host header" });
|
||||
return;
|
||||
}
|
||||
@@ -604,7 +604,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
||||
{ allowedOrigins, hostnames: config.hostnames },
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
|
||||
@@ -11,11 +11,7 @@ import type {
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||
import {
|
||||
mergeAllowedHosts,
|
||||
parseAllowedHostsEnv,
|
||||
type AllowedHostsConfig,
|
||||
} from "./allowed-hosts.js";
|
||||
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
@@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{
|
||||
relayEnabled: boolean;
|
||||
mcpEnabled: boolean;
|
||||
mcpInjectIntoAgents: boolean;
|
||||
allowedHosts: AllowedHostsConfig;
|
||||
hostnames: HostnamesConfig;
|
||||
}>;
|
||||
|
||||
const OptionalVoiceLlmProviderSchema = z
|
||||
@@ -133,10 +129,10 @@ export function loadConfig(
|
||||
|
||||
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
||||
|
||||
const allowedHosts = mergeAllowedHosts([
|
||||
persisted.daemon?.allowedHosts,
|
||||
parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
|
||||
options?.cli?.allowedHosts,
|
||||
const hostnames = mergeHostnames([
|
||||
persisted.daemon?.hostnames,
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
options?.cli?.hostnames,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
@@ -181,7 +177,7 @@ export function loadConfig(
|
||||
corsAllowedOrigins: Array.from(
|
||||
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
||||
),
|
||||
allowedHosts,
|
||||
hostnames,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
|
||||
56
packages/server/src/server/hostnames.test.ts
Normal file
56
packages/server/src/server/hostnames.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PersistedConfigSchema } from "./persisted-config.js";
|
||||
import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js";
|
||||
|
||||
describe("hostnames (vite-style)", () => {
|
||||
it("allows localhost by default", () => {
|
||||
expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows subdomains of .localhost by default", () => {
|
||||
expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows IP addresses by default", () => {
|
||||
expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true);
|
||||
expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-default hosts when no allowlist is provided", () => {
|
||||
expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows any host when set to true", () => {
|
||||
expect(isHostnameAllowed("evil.com:6767", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports leading-dot patterns", () => {
|
||||
const hostnames = [".example.com"];
|
||||
expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true);
|
||||
expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true);
|
||||
expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true);
|
||||
expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false);
|
||||
});
|
||||
|
||||
it("merges arrays (append + de-dupe) and short-circuits on true", () => {
|
||||
expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]);
|
||||
expect(mergeHostnames([["a"], true, ["b"]])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses env var values", () => {
|
||||
expect(parseHostnamesEnv(undefined)).toBeUndefined();
|
||||
expect(parseHostnamesEnv("")).toBeUndefined();
|
||||
expect(parseHostnamesEnv("true")).toBe(true);
|
||||
expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]);
|
||||
});
|
||||
|
||||
it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
allowedHosts: [".example.com"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.hostnames).toEqual([".example.com"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import net from "node:net";
|
||||
|
||||
export type AllowedHostsConfig = true | string[] | undefined;
|
||||
export type HostnamesConfig = true | string[] | undefined;
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.trim().toLowerCase();
|
||||
@@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null {
|
||||
return normalizeHostname(trimmed.slice(0, colonIndex));
|
||||
}
|
||||
|
||||
function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
|
||||
function matchesHostnamePattern(hostname: string, pattern: string): boolean {
|
||||
const normalizedPattern = normalizeHostname(pattern);
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
@@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite-style allowed hosts check, adapted to raw Host headers.
|
||||
* Vite-style hostname allowlist check, adapted to raw Host headers.
|
||||
*
|
||||
* Semantics:
|
||||
* - `allowedHosts === true` => allow any host.
|
||||
* - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
||||
* - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
||||
* - `hostnames === true` => allow any host.
|
||||
* - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
||||
* - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
||||
*/
|
||||
export function isHostAllowed(
|
||||
export function isHostnameAllowed(
|
||||
hostHeader: string | undefined,
|
||||
allowedHosts: AllowedHostsConfig,
|
||||
hostnames: HostnamesConfig,
|
||||
): boolean {
|
||||
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
|
||||
if (!hostname) return false;
|
||||
|
||||
if (allowedHosts === true) return true;
|
||||
if (hostnames === true) return true;
|
||||
|
||||
// Defaults are always allowed.
|
||||
if (isDefaultAllowedHostname(hostname)) return true;
|
||||
|
||||
const patterns = allowedHosts ?? [];
|
||||
const patterns = hostnames ?? [];
|
||||
for (const pattern of patterns) {
|
||||
if (matchesAllowedHostPattern(hostname, pattern)) return true;
|
||||
if (matchesHostnamePattern(hostname, pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
|
||||
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
|
||||
let merged: string[] = [];
|
||||
for (const value of values) {
|
||||
if (value === true) return true;
|
||||
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHos
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
|
||||
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user