mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 |
@@ -1,33 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
||||
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
||||
- Choose provider and model when creating scheduled agents.
|
||||
- Max reasoning effort option for Opus 4.6 models.
|
||||
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
||||
|
||||
### Improved
|
||||
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
||||
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
||||
- iPad and tablet layouts work correctly across all screen sizes.
|
||||
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
||||
|
||||
### Fixed
|
||||
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
||||
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
||||
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
||||
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
|
||||
- Loading indicators are visible in light mode.
|
||||
|
||||
## 0.1.54 - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# CI Test Status
|
||||
|
||||
Tracking progress toward all-green CI.
|
||||
|
||||
## CI Jobs
|
||||
|
||||
| Job | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| format | unknown | `npx biome format .` |
|
||||
| typecheck | unknown | `npm run typecheck` |
|
||||
| server-tests | unknown | unit + integration (vitest) |
|
||||
| app-tests | unknown | unit tests (vitest) |
|
||||
| playwright | unknown | E2E tests (playwright) |
|
||||
| relay-tests | unknown | unit tests (vitest) |
|
||||
| cli-tests | unknown | local tests |
|
||||
|
||||
## Log
|
||||
|
||||
- 2026-04-10: Branch created, automated agents begin iterating
|
||||
@@ -24,7 +24,6 @@ This is an npm workspace monorepo:
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
@@ -47,12 +46,6 @@ 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:
|
||||
|
||||
+1
-1
@@ -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 validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. 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: [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
# Custom Provider Configuration
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"provider-id": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
|
||||
- [Custom binary for a provider](#custom-binary-for-a-provider)
|
||||
- [Disabling a provider](#disabling-a-provider)
|
||||
- [ACP providers](#acp-providers)
|
||||
- [Provider override reference](#provider-override-reference)
|
||||
|
||||
---
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"description": "Claude with custom API endpoint",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-...",
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
|
||||
2. Create an API key from the Z.AI dashboard
|
||||
3. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available models
|
||||
|
||||
| Model | Tier |
|
||||
|---|---|
|
||||
| `glm-5.1` | Advanced (flagship) |
|
||||
| `glm-5-turbo` | Advanced |
|
||||
| `glm-4.7` | Standard |
|
||||
| `glm-4.5-air` | Lightweight |
|
||||
|
||||
### Notes
|
||||
|
||||
- `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
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
## Alibaba Cloud (Qwen) coding plan
|
||||
|
||||
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
|
||||
2. Subscribe to the Pro plan ($50/month)
|
||||
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
|
||||
4. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints
|
||||
|
||||
| Mode | Base URL |
|
||||
|---|---|
|
||||
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
||||
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||
|
||||
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Available models
|
||||
|
||||
**Recommended for coding plan:**
|
||||
|
||||
| Model | Notes |
|
||||
|---|---|
|
||||
| `qwen3.5-plus` | Vision capable, recommended |
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
|
||||
### Notes
|
||||
|
||||
- API keys must be created in the **Singapore region**
|
||||
- The coding plan is for personal use only in interactive coding tools
|
||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## Multiple profiles for the same provider
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-work": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Work)",
|
||||
"description": "Work Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-work-..."
|
||||
}
|
||||
},
|
||||
"claude-personal": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Personal)",
|
||||
"description": "Personal Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
|
||||
|
||||
You can also combine profiles with model overrides to pin specific models per profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-fast": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Fast)",
|
||||
"models": [
|
||||
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
|
||||
]
|
||||
},
|
||||
"claude-smart": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Smart)",
|
||||
"models": [
|
||||
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom binary for a provider
|
||||
|
||||
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
|
||||
|
||||
### Override a built-in provider's binary
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/opt/claude-nightly/claude"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use a custom wrapper script
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom binary on a derived provider
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "Codex (Custom Build)",
|
||||
"command": ["/home/user/codex-dev/target/release/codex"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
|
||||
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
"codex": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
## ACP providers
|
||||
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent-binary", "--acp"],
|
||||
"env": {
|
||||
"MY_API_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for ACP providers:
|
||||
- `extends: "acp"`
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
|
||||
|
||||
### Example: Hermes (Nous Research)
|
||||
|
||||
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
|
||||
|
||||
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
|
||||
2. Install ACP support: `pip install -e '.[acp]'`
|
||||
3. Configure Hermes credentials in `~/.hermes/`
|
||||
4. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"description": "Nous Research self-improving AI agent",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
|
||||
|
||||
### How ACP providers work in Paseo
|
||||
|
||||
When you launch an agent with an ACP provider:
|
||||
|
||||
1. Paseo spawns the process using the configured `command`
|
||||
2. Sends an `initialize` JSON-RPC request over stdin
|
||||
3. The agent responds with its capabilities, available modes, and models
|
||||
4. Paseo creates a session and sends prompts through the ACP protocol
|
||||
5. The agent streams responses, tool calls, and permission requests back over stdout
|
||||
|
||||
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"models": [
|
||||
{ "id": "fast-model", "label": "Fast", "isDefault": true },
|
||||
{ "id": "smart-model", "label": "Smart" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||
|
||||
---
|
||||
|
||||
## Provider override reference
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `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) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
Each entry in the `models` array:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Model identifier sent to the provider |
|
||||
| `label` | `string` | Yes | Display name in the UI |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
||||
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
||||
|
||||
### Thinking option
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Thinking option identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
### Full example
|
||||
|
||||
A config.json with multiple custom providers:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
},
|
||||
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
]
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
},
|
||||
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+1
-1
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
allowedHosts: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
|
||||
+6
-10
@@ -9,10 +9,6 @@ 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";
|
||||
|
||||
@@ -62,12 +58,12 @@ in
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
hostnames = lib.mkOption {
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
@@ -145,10 +141,10 @@ in
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
|
||||
+1
-1
@@ -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-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8=";
|
||||
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||
|
||||
# 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).
|
||||
|
||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@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.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35340,7 +35340,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35366,7 +35366,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35382,14 +35382,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35817,7 +35817,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -14,7 +14,6 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
@@ -37,7 +36,6 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -224,51 +224,6 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
@@ -478,6 +433,12 @@ export default async function globalSetup() {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
@@ -512,6 +473,26 @@ export default async function globalSetup() {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
@@ -542,10 +523,17 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isPaneFocused: boolean;
|
||||
isInputActive: 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,
|
||||
isPaneFocused,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
@@ -421,7 +421,7 @@ export function AgentInputArea({
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isPaneFocused) {
|
||||
if (!isInputActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ export function AgentInputArea({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isPaneFocused],
|
||||
[isInputActive],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -475,9 +475,9 @@ export function AgentInputArea({
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isPaneFocused,
|
||||
enabled: isInputActive,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isPaneFocused,
|
||||
isActive: () => isInputActive,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
@@ -736,7 +736,7 @@ export function AgentInputArea({
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
|
||||
@@ -1225,8 +1225,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
prefsButton: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
@@ -25,7 +24,6 @@ export function BranchSwitcher({
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -43,17 +41,12 @@ export function BranchSwitcher({
|
||||
queryClient,
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||
if (!currentBranchName) {
|
||||
return (
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -68,8 +61,11 @@ export function BranchSwitcher({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
@@ -115,14 +111,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: 0,
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -634,9 +634,7 @@ export function CombinedModelSelector({
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
@@ -706,8 +704,6 @@ export function CombinedModelSelector({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -725,8 +721,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -264,7 +264,6 @@ export function FilePane({
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,11 +32,15 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
useCheckoutDiffMetadataQuery,
|
||||
type DiffFileMetadata,
|
||||
} from "@/hooks/use-checkout-diff-metadata-query";
|
||||
import { useFileHunks } from "@/hooks/use-file-hunks";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffFileHeaderFields {
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeaderFields;
|
||||
isExpanded: boolean;
|
||||
onToggle: (path: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
@@ -617,6 +629,109 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
interface LazyDiffFileBodyProps {
|
||||
file: DiffFileMetadata;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function LazyDiffFileBody({
|
||||
file,
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
layout,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: LazyDiffFileBodyProps) {
|
||||
const {
|
||||
file: resolvedFile,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !resolvedFile) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{error instanceof Error ? error.message : "Failed to load diff"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={resolvedFile}
|
||||
layout={layout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={onBodyHeightChange}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
@@ -625,8 +740,8 @@ interface GitDiffPaneProps {
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
} = useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
stickyIndices.push(items.length - 1);
|
||||
}
|
||||
if (isExpanded) {
|
||||
items.push({ type: "body", file, fileIndex: i });
|
||||
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffFileBody
|
||||
<LazyDiffFileBody
|
||||
file={item.file}
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
mode={diffMode}
|
||||
baseRef={baseRef}
|
||||
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
},
|
||||
[
|
||||
baseRef,
|
||||
changesPreferences.hideWhitespace,
|
||||
cwd,
|
||||
diffMode,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
serverId,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -42,8 +42,6 @@ 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 {
|
||||
markScrollInvestigationEvent,
|
||||
@@ -77,8 +75,8 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isPaneFocused?: boolean;
|
||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isInputActive?: 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) */
|
||||
@@ -208,7 +206,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isPaneFocused = true,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
@@ -235,9 +233,7 @@ 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>(
|
||||
@@ -431,7 +427,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||
autoStopWhenHidden: { isVisible: isInputActive },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
@@ -1001,12 +997,10 @@ 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={[
|
||||
@@ -1031,11 +1025,6 @@ 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 */}
|
||||
@@ -1281,14 +1270,6 @@ 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,
|
||||
@@ -1309,15 +1290,11 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
leftButtonGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||
|
||||
@@ -236,14 +236,7 @@ function WorkspaceStatusIndicator({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -347,14 +340,7 @@ function ProjectLeadingVisual({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -808,12 +794,7 @@ function ProjectHeaderRow({
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={
|
||||
isHovered ||
|
||||
platformIsNative ||
|
||||
isMobileBreakpoint ||
|
||||
createWorktreeMutation.isPending
|
||||
}
|
||||
visible={isHovered || platformIsNative || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
|
||||
@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_ROWS = 3;
|
||||
const GRID_COLUMNS = 2;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
|
||||
|
||||
@@ -890,7 +890,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 0,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -1156,11 +1156,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
const existingWorkspace = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
});
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
} from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -219,24 +214,12 @@ 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",
|
||||
}),
|
||||
baseTail,
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
||||
interface DiffFileWithPath {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
||||
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
|
||||
if (files.length < 2) {
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useAgentAttentionClear({
|
||||
return;
|
||||
}
|
||||
deferredFocusEntryClearRef.current = false;
|
||||
client.clearAgentAttention(resolvedAgentId).catch(() => {});
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
},
|
||||
[agentId, attentionReason, client, isConnected, requiresAttention],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
|
||||
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
|
||||
const files: DiffFileMetadata[] = [
|
||||
createMetadata("zeta.ts"),
|
||||
createMetadata("alpha.ts"),
|
||||
createMetadata("beta.ts"),
|
||||
];
|
||||
const ordered = orderCheckoutDiffFiles(files);
|
||||
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
|
||||
});
|
||||
|
||||
it("replaces full list on snapshot — does not merge with previous", () => {
|
||||
const initial: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("b.ts", "fp2"),
|
||||
createMetadata("c.ts", "fp3"),
|
||||
];
|
||||
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
|
||||
const updated: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("c.ts", "fp4"),
|
||||
];
|
||||
|
||||
// Full replacement: the result is exactly the updated list, not a merge
|
||||
const result = orderCheckoutDiffFiles(updated);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
|
||||
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
|
||||
// b.ts is gone — no remnant from initial
|
||||
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
|
||||
|
||||
// Initial is unchanged (no mutation)
|
||||
expect(initial).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("preserves fingerprint and status on metadata items", () => {
|
||||
const file: DiffFileMetadata = {
|
||||
path: "large.bin",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
status: "binary",
|
||||
fingerprint: "abc123",
|
||||
};
|
||||
const ordered = orderCheckoutDiffFiles([file]);
|
||||
expect(ordered[0].fingerprint).toBe("abc123");
|
||||
expect(ordered[0].status).toBe("binary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutDiffSnapshot } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
|
||||
|
||||
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
|
||||
|
||||
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
|
||||
|
||||
function checkoutDiffMetadataQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return [
|
||||
"checkoutDiffMetadata",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffMetadataQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffMetadataQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutDiffMetadata(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd || !enabled) {
|
||||
return;
|
||||
}
|
||||
if (!isOpen || explorerTab !== "changes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = [
|
||||
"checkoutDiffMetadata",
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
|
||||
if (message.type !== "checkout/diff_snapshot") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: message.payload.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
void client
|
||||
.subscribeCheckoutDiffLazy(
|
||||
cwd,
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
|
||||
serverId,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeSnapshot();
|
||||
try {
|
||||
client.unsubscribeCheckoutDiffLazy(subscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect race during effect cleanup.
|
||||
}
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
cwd,
|
||||
enabled,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
|
||||
/**
|
||||
* Tests for use-file-hunks hook logic.
|
||||
*
|
||||
* The hook itself is a thin React Query wrapper, so we test the key properties:
|
||||
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
|
||||
* 2. Metadata fields are passed through to the merged result
|
||||
*/
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
describe("use-file-hunks query key", () => {
|
||||
it("includes fingerprint in query key", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
|
||||
expect(key).toContain("fp-abc");
|
||||
expect(key).toContain("foo.ts");
|
||||
});
|
||||
|
||||
it("different fingerprints produce different query keys", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
|
||||
expect(key1).not.toEqual(key2);
|
||||
// Only the fingerprint element differs
|
||||
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
|
||||
});
|
||||
|
||||
it("same fingerprint + same params produces identical key", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
it("normalizes missing baseRef to empty string", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
|
||||
expect(key[4]).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes ignoreWhitespace to boolean", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
|
||||
expect(key[5]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("use-file-hunks metadata merge", () => {
|
||||
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
|
||||
const meta: DiffFileMetadata = {
|
||||
path: "src/index.ts",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
status: "ok",
|
||||
fingerprint: "abc",
|
||||
};
|
||||
|
||||
// Simulates merging metadata with fetched hunks
|
||||
const merged = {
|
||||
path: meta.path,
|
||||
isNew: meta.isNew,
|
||||
isDeleted: meta.isDeleted,
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
status: meta.status,
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
expect(merged.path).toBe("src/index.ts");
|
||||
expect(merged.isNew).toBe(true);
|
||||
expect(merged.additions).toBe(10);
|
||||
expect(merged.hunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseFileHunksOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
file: DiffFileMetadata;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled = true,
|
||||
}: UseFileHunksOptions) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
fileHunksQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file.path,
|
||||
file.fingerprint,
|
||||
),
|
||||
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<ParsedDiffFile> => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutFileHunks(
|
||||
cwd,
|
||||
{ mode, baseRef, ignoreWhitespace },
|
||||
file.path,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: file.isNew,
|
||||
isDeleted: file.isDeleted,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
hunks: payload.hunks,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
file: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -284,12 +284,6 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
return;
|
||||
@@ -355,12 +349,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
cursor,
|
||||
error,
|
||||
});
|
||||
} catch {
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -791,32 +791,6 @@ 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}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isInputActive={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
images={agentInputDraft.images}
|
||||
|
||||
@@ -1232,7 +1232,7 @@ function DraftAgentScreenContent({
|
||||
<AgentInputArea
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isPaneFocused={isFocused}
|
||||
isInputActive={isFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -342,7 +342,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentInputArea
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isInputActive={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -141,18 +141,6 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
@@ -759,12 +747,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||
workspaceHeaderTitle,
|
||||
workspaceHeaderSubtitle,
|
||||
);
|
||||
|
||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||
const currentBranchName =
|
||||
@@ -1967,29 +1949,27 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
{isWorkspaceHeaderLoading ? (
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<>
|
||||
<View style={styles.headerTitleSkeleton} />
|
||||
<View style={styles.headerProjectTitleSkeleton} />
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.headerTitleTextGroup}>
|
||||
<>
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
title={workspaceHeaderTitle}
|
||||
title={workspaceHeader.title}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isGitCheckout={isGitCheckout}
|
||||
/>
|
||||
{shouldShowWorkspaceHeaderSubtitle ? (
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspaceHeaderSubtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
testID="workspace-header-subtitle"
|
||||
style={styles.headerProjectTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspaceHeader.subtitle}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -2311,40 +2291,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleContainer: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerTitleTextGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: {
|
||||
xs: 1,
|
||||
md: 0,
|
||||
},
|
||||
flexDirection: {
|
||||
xs: "column",
|
||||
md: "row",
|
||||
},
|
||||
alignItems: {
|
||||
xs: "flex-start",
|
||||
md: "center",
|
||||
},
|
||||
justifyContent: "flex-start",
|
||||
gap: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
headerProjectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: {
|
||||
xs: theme.fontSize.sm,
|
||||
md: theme.fontSize.base,
|
||||
},
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 1,
|
||||
},
|
||||
headerTitleSkeleton: {
|
||||
|
||||
@@ -134,14 +134,7 @@ export function WorkspaceTabIcon({
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
<SyncedLoader
|
||||
size={size - 1}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ export const baseColors = {
|
||||
// Amber scale
|
||||
amber: {
|
||||
500: "#f59e0b",
|
||||
700: "#b45309",
|
||||
},
|
||||
|
||||
// Yellow scale
|
||||
|
||||
@@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] {
|
||||
/**
|
||||
* Flush head items to tail, avoiding duplicates.
|
||||
*/
|
||||
export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] {
|
||||
if (head.length === 0) {
|
||||
return tail;
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
type WorkspaceLike = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName?: string;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarWorkspaceLike = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarProjectLike = {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceLike[];
|
||||
};
|
||||
|
||||
function countByValue(values: Iterable<string>): Record<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries(counts);
|
||||
}
|
||||
|
||||
export function summarizeWorkspaceCollection(
|
||||
workspaces: Iterable<WorkspaceLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(workspaces ?? [], (workspace) => ({
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName?.trim() || null,
|
||||
name: workspace.name,
|
||||
status: workspace.status,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
|
||||
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
|
||||
workspaces: entries,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSidebarProjects(
|
||||
projects: Iterable<SidebarProjectLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectKeys: string[];
|
||||
projects: Array<{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: Array<{
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(projects ?? [], (project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
statusBucket: project.statusBucket,
|
||||
activeCount: project.activeCount,
|
||||
totalWorkspaces: project.totalWorkspaces,
|
||||
workspaces: project.workspaces.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.statusBucket,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectKeys: entries.map((project) => project.projectKey),
|
||||
projects: entries,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
+4
-21
@@ -1,4 +1,4 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
@@ -124,27 +124,10 @@ export function createCli(): Command {
|
||||
.option("--no-relay", "Disable relay on restarted daemon")
|
||||
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
|
||||
.option(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
.action(withOutput(runDaemonRestartCommand));
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
program.addCommand(createAgentCommand());
|
||||
|
||||
@@ -16,7 +16,6 @@ import { resolve } from "node:path";
|
||||
import { lookup } from "mime-types";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export function addRunOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -99,6 +98,11 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
outputSchema?: string;
|
||||
}
|
||||
|
||||
interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
function toRunResult(
|
||||
agent: AgentSnapshotPayload,
|
||||
statusOverride?: AgentRunResult["status"],
|
||||
@@ -218,6 +222,54 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: Pick<AgentRunOptions, "provider" | "model">,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { startCommand } from "./start.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
import { runStopCommand } from "./stop.js";
|
||||
@@ -36,27 +36,10 @@ 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(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
.action(withOutput(runRestartCommand));
|
||||
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface DaemonStartOptions {
|
||||
relay?: boolean;
|
||||
mcp?: boolean;
|
||||
injectMcp?: boolean;
|
||||
hostnames?: string;
|
||||
allowedHosts?: 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.hostnames) {
|
||||
childEnv.PASEO_HOSTNAMES = options.hostnames;
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
|
||||
}
|
||||
return childEnv;
|
||||
}
|
||||
@@ -322,7 +322,6 @@ 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,
|
||||
hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined,
|
||||
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
|
||||
};
|
||||
|
||||
if (startOptions.listen && startOptions.port) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
startLocalDaemonForeground,
|
||||
@@ -9,10 +9,6 @@ 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")
|
||||
@@ -24,15 +20,11 @@ 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(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
|
||||
.action(async (options: RawStartCommandOptions) => {
|
||||
await runStart({
|
||||
...options,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
.action(async (options: StartOptions) => {
|
||||
await runStart(options);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
||||
import { Command, Option } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
@@ -24,10 +24,6 @@ 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 }
|
||||
@@ -68,7 +64,7 @@ function parseTimeoutMs(raw: string | undefined): number {
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
|
||||
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {};
|
||||
|
||||
if (options.listen) {
|
||||
@@ -81,9 +77,9 @@ function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
|
||||
cliOverrides.relayEnabled = false;
|
||||
}
|
||||
|
||||
if (options.hostnames) {
|
||||
const raw = options.hostnames.trim();
|
||||
cliOverrides.hostnames =
|
||||
if (options.allowedHosts) {
|
||||
const raw = options.allowedHosts.trim();
|
||||
cliOverrides.allowedHosts =
|
||||
raw.toLowerCase() === "true"
|
||||
? true
|
||||
: raw
|
||||
@@ -301,17 +297,13 @@ export function onboardCommand(): Command {
|
||||
.option("--no-relay", "Disable relay connection")
|
||||
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
|
||||
.option(
|
||||
"--hostnames <hosts>",
|
||||
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
|
||||
"--allowed-hosts <hosts>",
|
||||
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
|
||||
)
|
||||
.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: RawOnboardOptions) => {
|
||||
await runOnboard({
|
||||
...options,
|
||||
hostnames: options.hostnames ?? options.allowedHosts,
|
||||
});
|
||||
.action(async (options: OnboardOptions) => {
|
||||
await runOnboard(options);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
@@ -31,7 +30,6 @@ export async function runCreateCommand(
|
||||
cron: options.cron,
|
||||
name: options.name,
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
maxRuns: options.maxRuns,
|
||||
expiresIn: options.expiresIn,
|
||||
});
|
||||
|
||||
@@ -21,10 +21,6 @@ export function createScheduleCommand(): Command {
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ScheduleTarget,
|
||||
} from "./types.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export interface ScheduleCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
@@ -59,8 +58,7 @@ export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]
|
||||
if (target.type === "agent") {
|
||||
return `agent:${target.agentId.slice(0, 7)}`;
|
||||
}
|
||||
const modelSuffix = target.config.model ? `/${target.config.model}` : "";
|
||||
return `new-agent:${target.config.provider}${modelSuffix}`;
|
||||
return `new-agent:${target.config.provider}`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(durationMs: number): string {
|
||||
@@ -89,7 +87,6 @@ export function parseScheduleCreateInput(options: {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}): CreateScheduleInput {
|
||||
@@ -114,53 +111,29 @@ export function parseScheduleCreateInput(options: {
|
||||
: { type: "cron", expression: options.cron!.trim() };
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const hasExplicitProviderSelection = options.provider !== undefined;
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: options.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
const newAgentTarget: ScheduleTarget = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
let target: ScheduleTarget;
|
||||
if (!targetValue) {
|
||||
if (!targetValue || targetValue === "self") {
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (currentAgentId && !hasExplicitProviderSelection) {
|
||||
if (currentAgentId) {
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else {
|
||||
target = newAgentTarget;
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (targetValue === "self") {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--target self requires running inside a Paseo agent",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else if (targetValue === "new-agent") {
|
||||
target = newAgentTarget;
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = {
|
||||
type: "agent",
|
||||
agentId: targetValue,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { CommandError } from "../output/index.js";
|
||||
|
||||
export interface ResolveProviderAndModelOptions {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: ResolveProviderAndModelOptions,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || options.defaultProvider || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
resolveProviderAndModel,
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from "../src/commands/agent/run.ts";
|
||||
import { resolveProviderAndModel } from "../src/utils/provider-model.ts";
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
|
||||
@@ -23,11 +23,6 @@ try {
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.name, "review-prs");
|
||||
assert.strictEqual(createdJson.cadence, "every:5m");
|
||||
assert(
|
||||
typeof createdJson.target === "string" &&
|
||||
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
|
||||
created.stdout,
|
||||
);
|
||||
|
||||
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
@@ -58,61 +53,6 @@ try {
|
||||
console.log("schedule commands work\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1b: schedule create accepts provider/model syntax for new-agent runs");
|
||||
const created = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Refactor the API layer",
|
||||
"--every",
|
||||
"10m",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.target, "new-agent:codex/gpt-5.4");
|
||||
|
||||
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
|
||||
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
|
||||
const inspectedJson = JSON.parse(inspected.stdout);
|
||||
assert.strictEqual(inspectedJson.target.config.provider, "codex");
|
||||
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
console.log("schedule provider/model syntax works\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1c: schedule create rejects provider with self target");
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Conflicting schedule",
|
||||
"--every",
|
||||
"5m",
|
||||
"--target",
|
||||
"self",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for self target with provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--provider can only be used with a new-agent target"),
|
||||
"should explain provider target mismatch",
|
||||
);
|
||||
console.log("schedule rejects provider with self target\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: loop run/ls/inspect/logs/stop work");
|
||||
const run = await ctx.paseo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -8,13 +8,12 @@
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.57-rc.1",
|
||||
"@getpaseo/server": "0.1.57-rc.1",
|
||||
"@getpaseo/cli": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
|
||||
# Get a random available port for Metro
|
||||
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
||||
|
||||
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||
|
||||
# Allow any origin in dev so Electron on random ports works.
|
||||
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Desktop Dev (Windows)
|
||||
======================================================
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
======================================================
|
||||
"@
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
& "$RootDir\node_modules\.bin\concurrently" `
|
||||
--kill-others `
|
||||
--names "metro,electron" `
|
||||
--prefix-colors "magenta,cyan" `
|
||||
"cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" `
|
||||
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.57-rc.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"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.57-rc.1",
|
||||
"@getpaseo/relay": "0.1.57-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/relay": "0.1.55-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -516,6 +516,70 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("subscribes to lazy checkout diff updates via RPC handshake", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.subscribeCheckoutDiffLazy(
|
||||
"/tmp/project",
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId: "checkout-lazy-sub-1" },
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/subscribe_diff";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/subscribe_diff");
|
||||
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
|
||||
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
|
||||
});
|
||||
|
||||
test("getCheckoutFileHunks requests hunks via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.getCheckoutFileHunks(
|
||||
"/tmp/project",
|
||||
{ mode: "base", baseRef: "main" },
|
||||
"src/app.ts",
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/get_file_hunks";
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
path: string;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/get_file_hunks");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
expect(request.message.path).toBe("src/app.ts");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
path: "src/app.ts",
|
||||
hunks: [],
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
path: "src/app.ts",
|
||||
hunks: [],
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/unsubscribe_diff";
|
||||
subscriptionId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("requests branch suggestions via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
|
||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("resubscribes lazy checkout diff streams after reconnect", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const internal = client as unknown as {
|
||||
checkoutDiffLazySubscriptions: Map<
|
||||
string,
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>;
|
||||
};
|
||||
internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", {
|
||||
cwd: "/tmp/project",
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: false },
|
||||
});
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/subscribe_diff";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/subscribe_diff");
|
||||
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
expect(typeof request.message.requestId).toBe("string");
|
||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>["payload"];
|
||||
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
|
||||
type CheckoutDiffMetadataSnapshotPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/diff_snapshot" }
|
||||
>["payload"];
|
||||
type CheckoutFileHunksPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/file_hunks_response" }
|
||||
>["payload"];
|
||||
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
||||
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "set_daemon_config_response" }
|
||||
>;
|
||||
type CheckoutDiffSnapshotResponse = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/diff_snapshot" }
|
||||
>;
|
||||
type CorrelatedResponseMessage =
|
||||
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
|
||||
| CheckoutDiffSnapshotResponse
|
||||
| GetDaemonConfigResponse
|
||||
| SetDaemonConfigResponse;
|
||||
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
|
||||
@@ -616,6 +629,13 @@ export class DaemonClient {
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private checkoutDiffLazySubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
private slotTerminals = new Map<number, string>();
|
||||
@@ -1187,28 +1207,8 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
clearAgentAttention(agentId: string | string[]): void {
|
||||
this.sendSessionMessage({ type: "clear_agent_attention", agentId });
|
||||
}
|
||||
|
||||
sendHeartbeat(params: {
|
||||
@@ -1428,6 +1428,22 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeCheckoutDiffLazySubscriptions(): void {
|
||||
if (this.checkoutDiffLazySubscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
subscriptionId,
|
||||
cwd: subscription.cwd,
|
||||
compare: subscription.compare,
|
||||
requestId: this.createRequestId(),
|
||||
});
|
||||
this.sendSessionMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeTerminalDirectorySubscriptions(): void {
|
||||
if (this.terminalDirectorySubscriptions.size === 0) {
|
||||
return;
|
||||
@@ -1712,27 +1728,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async cancelAgent(agentId: string): Promise<void> {
|
||||
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;
|
||||
},
|
||||
});
|
||||
this.sendSessionMessage({ type: "cancel_agent_request", agentId });
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
@@ -2310,6 +2306,105 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckoutDiffMetadata(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<Omit<CheckoutDiffMetadataSnapshotPayload, "subscriptionId">> {
|
||||
const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`;
|
||||
try {
|
||||
const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, {
|
||||
subscriptionId: oneShotSubscriptionId,
|
||||
requestId,
|
||||
});
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
files: payload.files,
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect races during one-shot cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeCheckoutDiffLazy(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { subscriptionId?: string; requestId?: string },
|
||||
): Promise<CheckoutDiffMetadataSnapshotPayload> {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
|
||||
const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null;
|
||||
this.checkoutDiffLazySubscriptions.set(subscriptionId, {
|
||||
cwd,
|
||||
compare: normalizedCompare,
|
||||
});
|
||||
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
subscriptionId,
|
||||
cwd,
|
||||
compare: normalizedCompare,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
responseType: "checkout/diff_snapshot",
|
||||
timeout: 60000,
|
||||
options: { skipQueue: true },
|
||||
selectPayload: (payload) => {
|
||||
if (payload.subscriptionId !== subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (previousSubscription) {
|
||||
this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription);
|
||||
} else {
|
||||
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribeCheckoutDiffLazy(subscriptionId: string): void {
|
||||
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
|
||||
this.sendSessionMessage({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckoutFileHunks(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
path: string,
|
||||
requestId?: string,
|
||||
): Promise<CheckoutFileHunksPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout/get_file_hunks",
|
||||
cwd,
|
||||
compare: this.normalizeCheckoutDiffCompare(compare),
|
||||
path,
|
||||
},
|
||||
responseType: "checkout/file_hunks_response",
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
async checkoutCommit(
|
||||
cwd: string,
|
||||
input: { message?: string; addAll?: boolean },
|
||||
@@ -3763,6 +3858,7 @@ export class DaemonClient {
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||
this.resubscribeCheckoutDiffSubscriptions();
|
||||
this.resubscribeCheckoutDiffLazySubscriptions();
|
||||
this.resubscribeTerminalDirectorySubscriptions();
|
||||
this.flushPendingSendQueue();
|
||||
this.resolveConnect();
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
@@ -82,20 +81,13 @@ export async function createAgentManagementMcpServer(
|
||||
component: "agent-management-mcp",
|
||||
});
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: params?.provider ?? ("claude" as AgentProvider),
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
const server = new McpServer({
|
||||
name: "paseo-agent-management",
|
||||
@@ -664,9 +656,7 @@ export async function createAgentManagementMcpServer(
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
provider: AgentProviderEnum.optional(),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
allowedHosts: 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: [],
|
||||
hostnames: true,
|
||||
allowedHosts: 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: [],
|
||||
hostnames: true,
|
||||
allowedHosts: 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: [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
logger,
|
||||
});
|
||||
|
||||
let mcpAllowedHosts: string[] | undefined;
|
||||
let allowedHosts: 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,
|
||||
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
|
||||
...(allowedHosts ? { allowedHosts } : {}),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
|
||||
});
|
||||
});
|
||||
|
||||
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
||||
const url = `http://127.0.0.1:${port}/mcp/agents`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -478,28 +478,6 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule accepts provider/model syntax", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("inspect_schedule returns details", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
@@ -590,27 +568,6 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
setupFinishNotification,
|
||||
serializeSnapshotWithMetadata,
|
||||
@@ -231,36 +230,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return expandUserPath(trimmedCwd);
|
||||
};
|
||||
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const resolveNewAgentScheduleTarget = () => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (callerAgent) {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
: null;
|
||||
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: resolvedProvider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
|
||||
...(callerAgent.currentModeId
|
||||
? {
|
||||
modeId: mapModeAcrossProviders(
|
||||
callerAgent.currentModeId,
|
||||
callerAgent.provider,
|
||||
resolvedProvider,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedProviderModel?.model
|
||||
? { model: resolvedProviderModel.model }
|
||||
: !hasProviderOverride && callerAgent.config.model
|
||||
? { model: callerAgent.config.model }
|
||||
: {}),
|
||||
provider: callerAgent.provider,
|
||||
cwd: callerAgent.cwd,
|
||||
...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
|
||||
...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
|
||||
...(callerAgent.config.thinkingOptionId
|
||||
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
|
||||
: {}),
|
||||
@@ -288,17 +267,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: (() => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
};
|
||||
})(),
|
||||
config: {
|
||||
provider: "claude" as AgentProvider,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
};
|
||||
const agentToAgentInputSchema = {
|
||||
@@ -1209,16 +1181,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
@@ -1234,12 +1202,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!callerAgentId) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
if (provider !== undefined || cwd !== undefined) {
|
||||
throw new Error("provider and cwd can only be used with target=new-agent");
|
||||
}
|
||||
return { type: "agent" as const, agentId: callerAgentId };
|
||||
})()
|
||||
: resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
: resolveNewAgentScheduleTarget();
|
||||
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
|
||||
export const AgentProviderEnum = z.string();
|
||||
|
||||
@@ -49,49 +48,6 @@ export const AgentModelSchema = z.object({
|
||||
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
export const AGENT_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: AgentProvider;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider: AgentProvider;
|
||||
}): ResolvedProviderModel {
|
||||
const providerInput = params.provider?.trim() || params.defaultProvider;
|
||||
const modelInput = params.model?.trim();
|
||||
|
||||
if (params.model !== undefined && !modelInput) {
|
||||
throw new Error("model cannot be empty");
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput as AgentProvider,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
throw new Error("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
throw new Error(
|
||||
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider as AgentProvider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
};
|
||||
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
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,65 +315,6 @@ 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([
|
||||
|
||||
@@ -107,12 +107,6 @@ 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({
|
||||
@@ -237,73 +231,6 @@ 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) {
|
||||
@@ -312,49 +239,6 @@ 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));
|
||||
@@ -686,7 +570,6 @@ export const __openCodeInternals = {
|
||||
hasNormalizedOpenCodeUsage,
|
||||
mergeOpenCodeStepFinishUsage,
|
||||
parseOpenCodeModelLookupKey,
|
||||
reconcileOpenCodeSessionClose,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
};
|
||||
@@ -1647,17 +1530,6 @@ 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 },
|
||||
@@ -1671,17 +1543,6 @@ 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,
|
||||
@@ -2071,12 +1932,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import net from "node:net";
|
||||
|
||||
export type HostnamesConfig = true | string[] | undefined;
|
||||
export type AllowedHostsConfig = 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 matchesHostnamePattern(hostname: string, pattern: string): boolean {
|
||||
function matchesAllowedHostPattern(hostname: string, pattern: string): boolean {
|
||||
const normalizedPattern = normalizeHostname(pattern);
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
@@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite-style hostname allowlist check, adapted to raw Host headers.
|
||||
* Vite-style allowed hosts check, adapted to raw Host headers.
|
||||
*
|
||||
* Semantics:
|
||||
* - `hostnames === true` => allow any host.
|
||||
* - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
||||
* - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
||||
* - `allowedHosts === true` => allow any host.
|
||||
* - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs.
|
||||
* - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults.
|
||||
*/
|
||||
export function isHostnameAllowed(
|
||||
export function isHostAllowed(
|
||||
hostHeader: string | undefined,
|
||||
hostnames: HostnamesConfig,
|
||||
allowedHosts: AllowedHostsConfig,
|
||||
): boolean {
|
||||
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
|
||||
if (!hostname) return false;
|
||||
|
||||
if (hostnames === true) return true;
|
||||
if (allowedHosts === true) return true;
|
||||
|
||||
// Defaults are always allowed.
|
||||
if (isDefaultAllowedHostname(hostname)) return true;
|
||||
|
||||
const patterns = hostnames ?? [];
|
||||
const patterns = allowedHosts ?? [];
|
||||
for (const pattern of patterns) {
|
||||
if (matchesHostnamePattern(hostname, pattern)) return true;
|
||||
if (matchesAllowedHostPattern(hostname, pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
|
||||
export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): AllowedHostsConfig {
|
||||
let merged: string[] = [];
|
||||
for (const value of values) {
|
||||
if (value === true) return true;
|
||||
@@ -85,7 +85,7 @@ export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
|
||||
export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig {
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
@@ -1,11 +1,11 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
@@ -150,20 +150,30 @@ describe("paseo daemon bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("generates a relay pairing offer for unix socket listeners", async () => {
|
||||
test("emits a relay pairing offer for unix socket listeners", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
|
||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
const lines: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
lines.push(chunk.toString("utf8"));
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: socketPath,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
@@ -181,16 +191,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayPublicEndpoint: "127.0.0.1:9",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
includeQr: false,
|
||||
});
|
||||
expect(pairing.relayEnabled).toBe(true);
|
||||
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||
expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true);
|
||||
} finally {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await daemon.agentManager.flush().catch(() => undefined);
|
||||
|
||||
@@ -107,7 +107,6 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
@@ -119,7 +118,7 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
|
||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||
|
||||
@@ -162,7 +161,7 @@ export type PaseoDaemonConfig = {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
corsAllowedOrigins: string[];
|
||||
hostnames?: HostnamesConfig;
|
||||
allowedHosts?: AllowedHostsConfig;
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
staticDir: string;
|
||||
@@ -231,7 +230,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 (!isHostnameAllowed(hostHeader, config.hostnames)) {
|
||||
if (!isHostAllowed(hostHeader, config.allowedHosts)) {
|
||||
res.status(403).json({ error: "Invalid Host header" });
|
||||
return;
|
||||
}
|
||||
@@ -369,10 +368,6 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
});
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
@@ -386,7 +381,6 @@ export async function createPaseoDaemon(
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||
@@ -395,7 +389,6 @@ export async function createPaseoDaemon(
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
workspaceGitService,
|
||||
});
|
||||
const loopService = new LoopService({
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -604,7 +597,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: config.hostnames },
|
||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
||||
speechService,
|
||||
terminalManager,
|
||||
{
|
||||
@@ -626,7 +619,6 @@ export async function createPaseoDaemon(
|
||||
loopService,
|
||||
scheduleService,
|
||||
checkoutDiffManager,
|
||||
workspaceGitService,
|
||||
);
|
||||
|
||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||
@@ -646,7 +638,8 @@ export async function createPaseoDaemon(
|
||||
relay: { endpoint: relayPublicEndpoint },
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
logger.info({ url }, "pairing_offer");
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
|
||||
@@ -1,184 +1,282 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
|
||||
() => {
|
||||
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
||||
readdirMock: vi.fn(async (directory: string) => {
|
||||
if (directory === "/tmp/repo") {
|
||||
return [
|
||||
{ name: "packages", isDirectory: () => true },
|
||||
{ name: ".git", isDirectory: () => true },
|
||||
{ name: "README.md", isDirectory: () => false },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages")) {
|
||||
return [
|
||||
{ name: "server", isDirectory: () => true },
|
||||
{ name: "app", isDirectory: () => true },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||
return [{ name: "src", isDirectory: () => true }];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
||||
return [{ name: "server", isDirectory: () => true }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
watchCalls: hoistedWatchCalls,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../utils/run-git-command.js", () => ({
|
||||
runGitCommand: vi.fn(async () => ({
|
||||
stdout: "/tmp/repo\n",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
readdir: readdirMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn((watchPath: string) => {
|
||||
const close = vi.fn();
|
||||
watchCalls.push({ path: watchPath, close });
|
||||
return {
|
||||
close,
|
||||
on: vi.fn().mockReturnThis(),
|
||||
} as any;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/checkout-git.js", () => ({
|
||||
getCheckoutDiff: getCheckoutDiffMock,
|
||||
}));
|
||||
|
||||
vi.mock("./checkout-git-utils.js", () => ({
|
||||
toCheckoutError: toCheckoutErrorMock,
|
||||
READ_ONLY_GIT_ENV: {},
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
toCheckoutError: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
|
||||
describe("CheckoutDiffManager", () => {
|
||||
function createStructuredFile(options?: {
|
||||
path?: string;
|
||||
addContent?: string;
|
||||
tokenStyle?: string;
|
||||
}): {
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
hunks: Array<{
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>;
|
||||
}>;
|
||||
status: "ok";
|
||||
} {
|
||||
const addContent = options?.addContent ?? "const value = 2;";
|
||||
return {
|
||||
path: options?.path ?? "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,1 +1,2 @@" },
|
||||
{ type: "context", content: "const value = 1;" },
|
||||
{
|
||||
type: "add",
|
||||
content: addContent,
|
||||
tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
describe("CheckoutDiffManager Linux watchers", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getCheckoutDiffMock.mockReset();
|
||||
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
|
||||
toCheckoutErrorMock.mockClear();
|
||||
watchCalls.length = 0;
|
||||
getCheckoutDiffMock.mockClear();
|
||||
resolveCheckoutGitDirMock.mockClear();
|
||||
readdirMock.mockClear();
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "linux",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
function createManager(options?: {
|
||||
repoRoot?: string | null;
|
||||
getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
|
||||
}) {
|
||||
const unsubscribe = vi.fn();
|
||||
let onChange: (() => void) | null = null;
|
||||
const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
|
||||
onChange = listener;
|
||||
return {
|
||||
repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
|
||||
unsubscribe,
|
||||
};
|
||||
test("watches nested repository directories on Linux", async () => {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const workspaceGitService = {
|
||||
subscribe: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
const subscription = await manager.subscribe(
|
||||
{
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
if (options?.getCheckoutDiffImplementation) {
|
||||
getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
|
||||
}
|
||||
expect(subscription.initial.error).toBeNull();
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/.git",
|
||||
"/tmp/repo/packages",
|
||||
"/tmp/repo/packages/app",
|
||||
"/tmp/repo/packages/server",
|
||||
"/tmp/repo/packages/server/src",
|
||||
"/tmp/repo/packages/server/src/server",
|
||||
]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("subscribeLazy returns metadata snapshots and caches file hunks", async () => {
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile()],
|
||||
});
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
});
|
||||
|
||||
return {
|
||||
manager,
|
||||
workspaceGitService,
|
||||
mockRequestWorkingTreeWatch,
|
||||
unsubscribe,
|
||||
getOnChange: () => onChange,
|
||||
};
|
||||
}
|
||||
|
||||
test("subscribe requests a working tree watch with the correct cwd", async () => {
|
||||
const { manager, mockRequestWorkingTreeWatch } = createManager();
|
||||
|
||||
await manager.subscribe(
|
||||
{
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
|
||||
"/tmp/repo/packages/server",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("unsubscribe calls the working tree watch unsubscribe", async () => {
|
||||
const { manager, unsubscribe } = createManager();
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("diffCwd uses repoRoot from the working tree watch result", async () => {
|
||||
const { manager } = createManager({ repoRoot: "/tmp/repo" });
|
||||
|
||||
await manager.subscribe(
|
||||
{
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
);
|
||||
});
|
||||
|
||||
test("diff refresh is triggered when the working tree watch callback fires", async () => {
|
||||
getCheckoutDiffMock
|
||||
.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
||||
});
|
||||
|
||||
const { manager, getOnChange } = createManager();
|
||||
const listener = vi.fn();
|
||||
|
||||
await manager.subscribe(
|
||||
const subscription = await manager.subscribeLazy(
|
||||
{
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
listener,
|
||||
);
|
||||
|
||||
const onChange = getOnChange();
|
||||
expect(onChange).toBeTypeOf("function");
|
||||
expect(subscription.initial.error).toBeNull();
|
||||
expect(subscription.initial.files).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint: expect.any(String),
|
||||
}),
|
||||
]);
|
||||
expect((subscription.initial.files[0] as any).hunks).toBeUndefined();
|
||||
expect(
|
||||
manager.getFileHunks(
|
||||
path.join("/tmp/repo", "packages", "server"),
|
||||
{ mode: "uncommitted" },
|
||||
"src/example.ts",
|
||||
),
|
||||
).toEqual(createStructuredFile().hunks);
|
||||
|
||||
onChange?.();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/repo/packages/server",
|
||||
files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
||||
error: null,
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("falls back to cwd when the working tree watch returns no repo root", async () => {
|
||||
const { manager } = createManager({ repoRoot: null });
|
||||
test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => {
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ tokenStyle: "keyword" })],
|
||||
});
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ tokenStyle: "variableName" })],
|
||||
});
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ addContent: "const value = 3;" })],
|
||||
});
|
||||
|
||||
await manager.subscribe(
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
await manager.subscribeLazy(
|
||||
{
|
||||
cwd: "/tmp/plain",
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
listener,
|
||||
);
|
||||
|
||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||
"/tmp/plain",
|
||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
const target = Array.from((manager as any).targets.values())[0];
|
||||
await (manager as any).refreshTarget(target);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
await (manager as any).refreshTarget(target);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
path: "src/example.ts",
|
||||
fingerprint: expect.any(String),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
|
||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
||||
|
||||
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
|
||||
|
||||
@@ -14,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
|
||||
"subscriptionId"
|
||||
>;
|
||||
|
||||
type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
|
||||
|
||||
export type { DiffFileMetadata };
|
||||
|
||||
export type CheckoutLazyDiffSnapshotPayload = {
|
||||
cwd: string;
|
||||
files: DiffFileMetadata[];
|
||||
error: CheckoutDiffSnapshotPayload["error"];
|
||||
};
|
||||
|
||||
export type CheckoutDiffMetrics = {
|
||||
checkoutDiffTargetCount: number;
|
||||
checkoutDiffSubscriptionCount: number;
|
||||
@@ -27,26 +43,31 @@ type CheckoutDiffWatchTarget = {
|
||||
diffCwd: string;
|
||||
compare: CheckoutDiffCompareInput;
|
||||
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
||||
workingTreeWatchUnsubscribe: (() => void) | null;
|
||||
lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
|
||||
watchers: FSWatcher[];
|
||||
fallbackRefreshInterval: NodeJS.Timeout | null;
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
refreshPromise: Promise<void> | null;
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
latestFilesByPath: Map<string, ParsedDiffFile>;
|
||||
latestMetadataFingerprints: Map<string, string>;
|
||||
watchedPaths: Set<string>;
|
||||
repoWatchPath: string | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | null;
|
||||
linuxTreeRefreshQueued: boolean;
|
||||
};
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
paseoHome: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
}) {
|
||||
constructor(options: { logger: pino.Logger; paseoHome: string }) {
|
||||
this.logger = options.logger.child({ module: "checkout-diff-manager" });
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
@@ -67,6 +88,7 @@ export class CheckoutDiffManager {
|
||||
diffCwd: target.diffCwd,
|
||||
}));
|
||||
target.latestPayload = initial;
|
||||
target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
|
||||
target.latestFingerprint = JSON.stringify(initial);
|
||||
return {
|
||||
initial,
|
||||
@@ -76,6 +98,36 @@ export class CheckoutDiffManager {
|
||||
};
|
||||
}
|
||||
|
||||
async subscribeLazy(
|
||||
params: {
|
||||
cwd: string;
|
||||
compare: CheckoutDiffCompareInput;
|
||||
},
|
||||
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
|
||||
): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> {
|
||||
const cwd = params.cwd;
|
||||
const compare = this.normalizeCompare(params.compare);
|
||||
const target = await this.ensureTarget(cwd, compare);
|
||||
target.lazyListeners.add(listener);
|
||||
|
||||
const baseSnapshot =
|
||||
target.latestPayload ??
|
||||
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
|
||||
diffCwd: target.diffCwd,
|
||||
}));
|
||||
target.latestPayload = baseSnapshot;
|
||||
target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file]));
|
||||
const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot);
|
||||
target.latestLazyPayload = initial;
|
||||
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files);
|
||||
return {
|
||||
initial,
|
||||
unsubscribe: () => {
|
||||
this.removeLazyListener(target.key, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): void {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
for (const target of this.targets.values()) {
|
||||
@@ -88,16 +140,23 @@ export class CheckoutDiffManager {
|
||||
|
||||
getMetrics(): CheckoutDiffMetrics {
|
||||
let checkoutDiffSubscriptionCount = 0;
|
||||
let checkoutDiffWatcherCount = 0;
|
||||
let checkoutDiffFallbackRefreshTargetCount = 0;
|
||||
|
||||
for (const target of this.targets.values()) {
|
||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
||||
checkoutDiffSubscriptionCount += target.lazyListeners.size;
|
||||
checkoutDiffWatcherCount += target.watchers.length;
|
||||
if (target.fallbackRefreshInterval) {
|
||||
checkoutDiffFallbackRefreshTargetCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutDiffTargetCount: this.targets.size,
|
||||
checkoutDiffSubscriptionCount,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
checkoutDiffWatcherCount,
|
||||
checkoutDiffFallbackRefreshTargetCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,9 +192,19 @@ export class CheckoutDiffManager {
|
||||
clearTimeout(target.debounceTimer);
|
||||
target.debounceTimer = null;
|
||||
}
|
||||
target.workingTreeWatchUnsubscribe?.();
|
||||
target.workingTreeWatchUnsubscribe = null;
|
||||
if (target.fallbackRefreshInterval) {
|
||||
clearInterval(target.fallbackRefreshInterval);
|
||||
target.fallbackRefreshInterval = null;
|
||||
}
|
||||
for (const watcher of target.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
target.lazyListeners.clear();
|
||||
target.latestFilesByPath.clear();
|
||||
target.latestMetadataFingerprints.clear();
|
||||
}
|
||||
|
||||
private removeListener(
|
||||
@@ -147,13 +216,54 @@ export class CheckoutDiffManager {
|
||||
return;
|
||||
}
|
||||
target.listeners.delete(listener);
|
||||
if (target.listeners.size > 0) {
|
||||
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
this.closeTarget(target);
|
||||
this.targets.delete(targetKey);
|
||||
}
|
||||
|
||||
private removeLazyListener(
|
||||
targetKey: string,
|
||||
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
|
||||
): void {
|
||||
const target = this.targets.get(targetKey);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.lazyListeners.delete(listener);
|
||||
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
this.closeTarget(target);
|
||||
this.targets.delete(targetKey);
|
||||
}
|
||||
|
||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const root = stdout.trim();
|
||||
return root.length > 0 ? root : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getFileHunks(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompareInput,
|
||||
path: string,
|
||||
): ParsedDiffFile["hunks"] | null {
|
||||
const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare)));
|
||||
return target?.latestFilesByPath.get(path)?.hunks ?? null;
|
||||
}
|
||||
|
||||
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
||||
if (target.debounceTimer) {
|
||||
clearTimeout(target.debounceTimer);
|
||||
@@ -164,6 +274,81 @@ export class CheckoutDiffManager {
|
||||
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private buildLazySnapshot(
|
||||
snapshot: CheckoutDiffSnapshotPayload,
|
||||
): CheckoutLazyDiffSnapshotPayload {
|
||||
return {
|
||||
cwd: snapshot.cwd,
|
||||
files: this.deriveMetadata(snapshot.files),
|
||||
error: snapshot.error,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] {
|
||||
return files.map((file) => ({
|
||||
path: file.path,
|
||||
isNew: file.isNew,
|
||||
isDeleted: file.isDeleted,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
...(file.status ? { status: file.status } : {}),
|
||||
fingerprint: this.buildFileFingerprint(file),
|
||||
}));
|
||||
}
|
||||
|
||||
private buildFileFingerprint(file: ParsedDiffFile): string {
|
||||
const serialized = JSON.stringify({
|
||||
hunks: file.hunks.map((hunk) => ({
|
||||
oldStart: hunk.oldStart,
|
||||
oldCount: hunk.oldCount,
|
||||
newStart: hunk.newStart,
|
||||
newCount: hunk.newCount,
|
||||
lines: hunk.lines.map((line) => ({
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
return createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map<string, string> {
|
||||
return new Map(
|
||||
files.map((file) => [
|
||||
file.path,
|
||||
createHash("sha256").update(JSON.stringify(file)).digest("hex"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private metadataSnapshotChanged(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
snapshot: CheckoutLazyDiffSnapshotPayload,
|
||||
): boolean {
|
||||
if (!target.latestLazyPayload) {
|
||||
return true;
|
||||
}
|
||||
if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
target.latestLazyPayload.error?.code !== snapshot.error?.code ||
|
||||
target.latestLazyPayload.error?.message !== snapshot.error?.message
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files);
|
||||
if (nextFingerprints.size !== target.latestMetadataFingerprints.size) {
|
||||
return true;
|
||||
}
|
||||
for (const [path, fingerprint] of nextFingerprints) {
|
||||
if (target.latestMetadataFingerprints.get(path) !== fingerprint) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async computeCheckoutDiffSnapshot(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompareInput,
|
||||
@@ -213,6 +398,7 @@ export class CheckoutDiffManager {
|
||||
diffCwd: target.diffCwd,
|
||||
});
|
||||
target.latestPayload = snapshot;
|
||||
target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
|
||||
const fingerprint = JSON.stringify(snapshot);
|
||||
if (fingerprint !== target.latestFingerprint) {
|
||||
target.latestFingerprint = fingerprint;
|
||||
@@ -220,6 +406,17 @@ export class CheckoutDiffManager {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
const lazySnapshot = this.buildLazySnapshot(snapshot);
|
||||
if (this.metadataSnapshotChanged(target, lazySnapshot)) {
|
||||
target.latestLazyPayload = lazySnapshot;
|
||||
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files);
|
||||
for (const listener of target.lazyListeners) {
|
||||
listener(lazySnapshot);
|
||||
}
|
||||
} else {
|
||||
target.latestLazyPayload = lazySnapshot;
|
||||
}
|
||||
} while (target.refreshQueued);
|
||||
})();
|
||||
|
||||
@@ -240,27 +437,216 @@ export class CheckoutDiffManager {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||
const target: CheckoutDiffWatchTarget = {
|
||||
key: targetKey,
|
||||
cwd,
|
||||
diffCwd: cwd,
|
||||
diffCwd: watchRoot ?? cwd,
|
||||
compare,
|
||||
listeners: new Set(),
|
||||
workingTreeWatchUnsubscribe: null,
|
||||
lazyListeners: new Set(),
|
||||
watchers: [],
|
||||
fallbackRefreshInterval: null,
|
||||
debounceTimer: null,
|
||||
refreshPromise: null,
|
||||
refreshQueued: false,
|
||||
latestPayload: null,
|
||||
latestLazyPayload: null,
|
||||
latestFingerprint: null,
|
||||
latestFilesByPath: new Map(),
|
||||
latestMetadataFingerprints: new Map(),
|
||||
watchedPaths: new Set<string>(),
|
||||
repoWatchPath: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
linuxTreeRefreshQueued: false,
|
||||
};
|
||||
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
|
||||
cwd,
|
||||
() => this.scheduleTargetRefresh(target),
|
||||
);
|
||||
target.diffCwd = repoRoot ?? cwd;
|
||||
target.workingTreeWatchUnsubscribe = unsubscribe;
|
||||
|
||||
const repoWatchPath = watchRoot ?? cwd;
|
||||
target.repoWatchPath = repoWatchPath;
|
||||
const watchPaths = new Set<string>([repoWatchPath]);
|
||||
const gitDir = await resolveCheckoutGitDir(cwd);
|
||||
if (gitDir) {
|
||||
watchPaths.add(gitDir);
|
||||
}
|
||||
|
||||
let hasRecursiveRepoCoverage = false;
|
||||
const allowRecursiveRepoWatch = process.platform !== "linux";
|
||||
if (process.platform === "linux") {
|
||||
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
||||
}
|
||||
for (const watchPath of watchPaths) {
|
||||
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
||||
continue;
|
||||
}
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
|
||||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||||
hasRecursiveRepoCoverage = true;
|
||||
}
|
||||
}
|
||||
|
||||
const missingRepoCoverage = !hasRecursiveRepoCoverage;
|
||||
if (target.watchers.length === 0 || missingRepoCoverage) {
|
||||
target.fallbackRefreshInterval = setInterval(() => {
|
||||
this.scheduleTargetRefresh(target);
|
||||
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
|
||||
this.logger.warn(
|
||||
{
|
||||
cwd,
|
||||
compare,
|
||||
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
|
||||
reason:
|
||||
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
|
||||
},
|
||||
"Checkout diff watchers unavailable; using timed refresh fallback",
|
||||
);
|
||||
}
|
||||
|
||||
this.targets.set(targetKey, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private addWatcher(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
watchPath: string,
|
||||
shouldTryRecursive: boolean,
|
||||
): boolean {
|
||||
if (target.watchedPaths.has(watchPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { cwd, compare } = target;
|
||||
const onChange = () => {
|
||||
if (process.platform === "linux" && target.repoWatchPath) {
|
||||
void this.refreshLinuxRepoTreeWatchers(target);
|
||||
}
|
||||
this.scheduleTargetRefresh(target);
|
||||
};
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
watch(watchPath, { recursive }, () => {
|
||||
onChange();
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
target.watchedPaths.add(watchPath);
|
||||
return watcherIsRecursive;
|
||||
}
|
||||
|
||||
private async ensureLinuxRepoTreeWatchers(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
rootPath: string,
|
||||
): Promise<boolean> {
|
||||
const directories = await this.listLinuxWatchDirectories(rootPath);
|
||||
let complete = true;
|
||||
for (const directory of directories) {
|
||||
const watcherWasRecursive = this.addWatcher(target, directory, false);
|
||||
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return complete && target.watchedPaths.has(rootPath);
|
||||
}
|
||||
|
||||
private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise<void> {
|
||||
if (process.platform !== "linux" || !target.repoWatchPath) {
|
||||
return;
|
||||
}
|
||||
const rootPath = target.repoWatchPath;
|
||||
if (target.linuxTreeRefreshPromise) {
|
||||
target.linuxTreeRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
target.linuxTreeRefreshPromise = (async () => {
|
||||
do {
|
||||
target.linuxTreeRefreshQueued = false;
|
||||
try {
|
||||
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
cwd: target.cwd,
|
||||
compare: target.compare,
|
||||
rootPath,
|
||||
},
|
||||
"Failed to refresh Linux checkout diff tree watchers",
|
||||
);
|
||||
}
|
||||
} while (target.linuxTreeRefreshQueued);
|
||||
})();
|
||||
|
||||
try {
|
||||
await target.linuxTreeRefreshPromise;
|
||||
} finally {
|
||||
target.linuxTreeRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||
const directories: string[] = [];
|
||||
const pending = [rootPath];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
directories.push(directory);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
ServerInfoStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "../../shared/messages.js";
|
||||
import { DiffFileMetadataSchema } from "./rpc-schemas.js";
|
||||
|
||||
describe("checkout lazy RPC schemas", () => {
|
||||
test("parses checkout/subscribe_diff inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-1",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "/tmp/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/subscribe_diff");
|
||||
});
|
||||
|
||||
test("parses checkout/unsubscribe_diff inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "sub-1",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/unsubscribe_diff");
|
||||
});
|
||||
|
||||
test("parses checkout/get_file_hunks inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-2",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/index.ts",
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/get_file_hunks");
|
||||
});
|
||||
|
||||
test("parses checkout/diff_snapshot outbound messages", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "sub-1",
|
||||
requestId: "req-1",
|
||||
cwd: "/tmp/repo",
|
||||
files: [
|
||||
{
|
||||
path: "src/index.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
fingerprint: "fp-1",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/diff_snapshot");
|
||||
});
|
||||
|
||||
test("parses checkout/file_hunks_response outbound messages", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-3",
|
||||
path: "src/index.ts",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,1 +1,2 @@" },
|
||||
{ type: "context", content: "const a = 1;" },
|
||||
{ type: "add", content: "const b = 2;" },
|
||||
],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/file_hunks_response");
|
||||
});
|
||||
|
||||
test("requires fingerprint on diff metadata", () => {
|
||||
const result = DiffFileMetadataSchema.safeParse({
|
||||
path: "src/index.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts lazyDiffRpcs in server info features", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "server-1",
|
||||
hostname: null,
|
||||
version: null,
|
||||
capabilities: {},
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
lazyDiffRpcs: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.lazyDiffRpcs).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CheckoutDiffCompareSchema,
|
||||
CheckoutErrorSchema,
|
||||
DiffHunkSchema,
|
||||
} from "../../shared/messages.js";
|
||||
|
||||
export const DiffFileMetadataSchema = z.object({
|
||||
path: z.string(),
|
||||
isNew: z.boolean(),
|
||||
isDeleted: z.boolean(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
||||
fingerprint: z.string(),
|
||||
});
|
||||
|
||||
export type DiffFileMetadata = z.infer<typeof DiffFileMetadataSchema>;
|
||||
|
||||
export const CheckoutSubscribeDiffRequestSchema = z.object({
|
||||
type: z.literal("checkout/subscribe_diff"),
|
||||
requestId: z.string(),
|
||||
subscriptionId: z.string(),
|
||||
cwd: z.string(),
|
||||
compare: z.lazy(() => CheckoutDiffCompareSchema),
|
||||
});
|
||||
|
||||
export const CheckoutUnsubscribeDiffRequestSchema = z.object({
|
||||
type: z.literal("checkout/unsubscribe_diff"),
|
||||
subscriptionId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutGetFileHunksRequestSchema = z.object({
|
||||
type: z.literal("checkout/get_file_hunks"),
|
||||
requestId: z.string(),
|
||||
cwd: z.string(),
|
||||
path: z.string(),
|
||||
compare: z.lazy(() => CheckoutDiffCompareSchema),
|
||||
});
|
||||
|
||||
export const CheckoutDiffSnapshotSchema = z.object({
|
||||
type: z.literal("checkout/diff_snapshot"),
|
||||
payload: z.object({
|
||||
subscriptionId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
cwd: z.string(),
|
||||
files: z.array(DiffFileMetadataSchema),
|
||||
error: z.lazy(() => CheckoutErrorSchema).nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutFileHunksResponseSchema = z.object({
|
||||
type: z.literal("checkout/file_hunks_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
path: z.string(),
|
||||
hunks: z.array(z.lazy(() => DiffHunkSchema)),
|
||||
error: z.lazy(() => CheckoutErrorSchema).nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -11,7 +11,11 @@ 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 { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||
import {
|
||||
mergeAllowedHosts,
|
||||
parseAllowedHostsEnv,
|
||||
type AllowedHostsConfig,
|
||||
} from "./allowed-hosts.js";
|
||||
|
||||
const DEFAULT_PORT = 6767;
|
||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
@@ -38,7 +42,7 @@ export type CliConfigOverrides = Partial<{
|
||||
relayEnabled: boolean;
|
||||
mcpEnabled: boolean;
|
||||
mcpInjectIntoAgents: boolean;
|
||||
hostnames: HostnamesConfig;
|
||||
allowedHosts: AllowedHostsConfig;
|
||||
}>;
|
||||
|
||||
const OptionalVoiceLlmProviderSchema = z
|
||||
@@ -129,10 +133,10 @@ export function loadConfig(
|
||||
|
||||
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
|
||||
|
||||
const hostnames = mergeHostnames([
|
||||
persisted.daemon?.hostnames,
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
options?.cli?.hostnames,
|
||||
const allowedHosts = mergeAllowedHosts([
|
||||
persisted.daemon?.allowedHosts,
|
||||
parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS),
|
||||
options?.cli?.allowedHosts,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
@@ -177,7 +181,7 @@ export function loadConfig(
|
||||
corsAllowedOrigins: Array.from(
|
||||
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
|
||||
),
|
||||
hostnames,
|
||||
allowedHosts,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Writable } from "node:stream";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
function createCapturingLogger() {
|
||||
@@ -23,25 +22,18 @@ function createCapturingLogger() {
|
||||
return { logger, lines };
|
||||
}
|
||||
|
||||
async function getPairingOfferUrl(args: {
|
||||
paseoHome: string;
|
||||
relayEnabled?: boolean;
|
||||
relayEndpoint?: string;
|
||||
relayPublicEndpoint?: string;
|
||||
appBaseUrl?: string;
|
||||
}): Promise<string> {
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome: args.paseoHome,
|
||||
relayEnabled: args.relayEnabled,
|
||||
relayEndpoint: args.relayEndpoint,
|
||||
relayPublicEndpoint: args.relayPublicEndpoint,
|
||||
appBaseUrl: args.appBaseUrl,
|
||||
includeQr: false,
|
||||
});
|
||||
if (!pairing.url) {
|
||||
throw new Error("Expected relay pairing URL to be available");
|
||||
function parseOfferUrlFromLogs(lines: string[]): string {
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const obj = JSON.parse(line) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
return obj.url;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
return pairing.url;
|
||||
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): unknown {
|
||||
@@ -80,7 +72,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
test("emits relay-only offer URL with stable serverId", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
const { logger } = createCapturingLogger();
|
||||
const { logger, lines } = createCapturingLogger();
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
listen: "0.0.0.0",
|
||||
@@ -89,13 +81,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||
expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
|
||||
|
||||
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
|
||||
@@ -125,7 +111,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
|
||||
const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-"));
|
||||
|
||||
const { logger: logger1 } = createCapturingLogger();
|
||||
const { logger: logger1, lines: lines1 } = createCapturingLogger();
|
||||
const daemon1 = await createTestPaseoDaemon({
|
||||
listen: "0.0.0.0",
|
||||
logger: logger1,
|
||||
@@ -138,13 +124,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
let staticDir2: string | null = null;
|
||||
|
||||
try {
|
||||
const offerUrl1 = await getPairingOfferUrl({
|
||||
paseoHome: daemon1.paseoHome,
|
||||
relayEnabled: daemon1.config.relayEnabled,
|
||||
relayEndpoint: daemon1.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon1.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon1.config.appBaseUrl,
|
||||
});
|
||||
const offerUrl1 = parseOfferUrlFromLogs(lines1);
|
||||
const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as {
|
||||
serverId: string;
|
||||
daemonPublicKeyB64: string;
|
||||
@@ -153,7 +133,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
|
||||
await daemon1.close();
|
||||
|
||||
const { logger: logger2 } = createCapturingLogger();
|
||||
const { logger: logger2, lines: lines2 } = createCapturingLogger();
|
||||
const daemon2 = await createTestPaseoDaemon({
|
||||
listen: "0.0.0.0",
|
||||
logger: logger2,
|
||||
@@ -164,13 +144,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
staticDir2 = daemon2.staticDir;
|
||||
|
||||
try {
|
||||
const offerUrl2 = await getPairingOfferUrl({
|
||||
paseoHome: daemon2.paseoHome,
|
||||
relayEnabled: daemon2.config.relayEnabled,
|
||||
relayEndpoint: daemon2.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon2.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon2.config.appBaseUrl,
|
||||
});
|
||||
const offerUrl2 = parseOfferUrlFromLogs(lines2);
|
||||
const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as {
|
||||
serverId: string;
|
||||
daemonPublicKeyB64: string;
|
||||
@@ -233,6 +207,12 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
stdoutLines.push(text);
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
if (line.includes("pairing_offer")) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("unexpected pairing_offer log when --no-relay is set"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { msg?: string };
|
||||
if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue;
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
|
||||
@@ -27,25 +26,19 @@ function createCapturingLogger() {
|
||||
return { logger, lines };
|
||||
}
|
||||
|
||||
async function getPairingOfferUrl(args: {
|
||||
paseoHome: string;
|
||||
relayEnabled?: boolean;
|
||||
relayEndpoint?: string;
|
||||
relayPublicEndpoint?: string;
|
||||
appBaseUrl?: string;
|
||||
}): Promise<string> {
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome: args.paseoHome,
|
||||
relayEnabled: args.relayEnabled,
|
||||
relayEndpoint: args.relayEndpoint,
|
||||
relayPublicEndpoint: args.relayPublicEndpoint,
|
||||
appBaseUrl: args.appBaseUrl,
|
||||
includeQr: false,
|
||||
});
|
||||
if (!pairing.url) {
|
||||
throw new Error("Expected relay pairing URL to be available");
|
||||
function parseOfferUrlFromLogs(lines: string[]): string {
|
||||
for (const line of lines) {
|
||||
if (!line.includes("pairing_offer")) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
return obj.url;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return pairing.url;
|
||||
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): {
|
||||
@@ -206,13 +199,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
});
|
||||
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
|
||||
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
@@ -327,13 +314,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
});
|
||||
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
|
||||
// Previously, the daemon would time out waiting for `hello` and reconnect every ~10s.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -228,7 +228,6 @@ export const PersistedConfigSchema = z
|
||||
daemon: z
|
||||
.object({
|
||||
listen: z.string().optional(),
|
||||
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
||||
mcp: z
|
||||
.object({
|
||||
@@ -253,10 +252,6 @@ export const PersistedConfigSchema = z
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.transform(({ allowedHosts, ...daemon }) => {
|
||||
const hostnames = daemon.hostnames ?? allowedHosts;
|
||||
return hostnames === undefined ? daemon : { ...daemon, hostnames };
|
||||
})
|
||||
.optional(),
|
||||
|
||||
app: z
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { homedir } from "node:os";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { getCheckoutDiffFileMock } = vi.hoisted(() => ({
|
||||
getCheckoutDiffFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/checkout-git.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../utils/checkout-git.js")>(
|
||||
"../utils/checkout-git.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getCheckoutDiffFile: getCheckoutDiffFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { Session } from "./session.js";
|
||||
|
||||
function createSessionForCheckoutDiffTests(options?: {
|
||||
checkoutDiffManager?: {
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
subscribeLazy: ReturnType<typeof vi.fn>;
|
||||
getFileHunks: ReturnType<typeof vi.fn>;
|
||||
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
|
||||
getMetrics: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
const emitted: any[] = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const checkoutDiffManager =
|
||||
options?.checkoutDiffManager ??
|
||||
({
|
||||
subscribe: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
subscribeLazy: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
getFileHunks: vi.fn(() => null),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
} as const);
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: vi.fn(),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
checkoutDiffManager: checkoutDiffManager as any,
|
||||
workspaceGitService: {
|
||||
subscribe: async () => ({
|
||||
initial: {
|
||||
cwd: "/tmp/repo",
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
},
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getSnapshot: async () => null,
|
||||
refresh: async () => {},
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
|
||||
return { session, emitted, checkoutDiffManager };
|
||||
}
|
||||
|
||||
describe("Session checkout lazy diff handlers", () => {
|
||||
beforeEach(() => {
|
||||
getCheckoutDiffFileMock.mockReset();
|
||||
});
|
||||
|
||||
test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => {
|
||||
const firstUnsubscribe = vi.fn();
|
||||
const secondUnsubscribe = vi.fn();
|
||||
const subscribeLazy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
initial: {
|
||||
cwd: "/tmp/repo",
|
||||
files: [
|
||||
{
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint: "fp-1",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
unsubscribe: firstUnsubscribe,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: secondUnsubscribe,
|
||||
});
|
||||
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy,
|
||||
getFileHunks: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-1",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "~/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-2",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "~/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(subscribeLazy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(emitted[0]).toMatchObject({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "sub-1",
|
||||
requestId: "req-1",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => {
|
||||
const cachedHunks = [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
|
||||
},
|
||||
];
|
||||
const getFileHunks = vi.fn(() => cachedHunks);
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy: vi.fn(),
|
||||
getFileHunks,
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-hunks",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/example.ts",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(getFileHunks).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
{ mode: "uncommitted" },
|
||||
"src/example.ts",
|
||||
);
|
||||
expect(getCheckoutDiffFileMock).not.toHaveBeenCalled();
|
||||
expect(emitted[0]).toMatchObject({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-hunks",
|
||||
path: "src/example.ts",
|
||||
hunks: cachedHunks,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => {
|
||||
const unsubscribe = vi.fn();
|
||||
getCheckoutDiffFileMock.mockResolvedValueOnce({
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
|
||||
},
|
||||
],
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe,
|
||||
})),
|
||||
getFileHunks: vi.fn(() => null),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-sub",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "/tmp/repo",
|
||||
compare: { mode: "base", baseRef: "main" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-hunks",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/example.ts",
|
||||
compare: { mode: "base", baseRef: "main" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "sub-1",
|
||||
});
|
||||
|
||||
expect(getCheckoutDiffFileMock).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
{
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: undefined,
|
||||
includeStructured: true,
|
||||
},
|
||||
"src/example.ts",
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
);
|
||||
expect(emitted[1]).toMatchObject({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-hunks",
|
||||
path: "src/example.ts",
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
type CaptureTerminalRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type CheckoutSubscribeDiffRequest,
|
||||
type CheckoutUnsubscribeDiffRequest,
|
||||
type CheckoutGetFileHunksRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type EditorTargetDescriptorPayload,
|
||||
type EditorTargetId,
|
||||
@@ -114,7 +117,6 @@ import type {
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
checkoutLiteFromGitSnapshot,
|
||||
detectStaleWorkspaces,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
@@ -150,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutDiffFile,
|
||||
getCheckoutStatus,
|
||||
listBranchSuggestions,
|
||||
commitChanges,
|
||||
@@ -335,42 +338,6 @@ type FetchWorkspacesCursor = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status: FetchWorkspacesResponseEntry["status"];
|
||||
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
|
||||
activityAt: string | null;
|
||||
}>;
|
||||
} {
|
||||
const workspaces = Array.from(entries, (entry) => ({
|
||||
id: entry.id,
|
||||
projectId: entry.projectId,
|
||||
projectDisplayName: entry.projectDisplayName,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
workspaceKind: entry.workspaceKind,
|
||||
activityAt: entry.activityAt,
|
||||
}));
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const workspace of workspaces) {
|
||||
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
count: workspaces.length,
|
||||
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
|
||||
statusCounts: Object.fromEntries(statusCounts),
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -659,6 +626,7 @@ export class Session {
|
||||
private inflightRequests = 0;
|
||||
private peakInflightRequests = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
@@ -1404,7 +1372,7 @@ export class Session {
|
||||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||||
return buildProjectPlacementForCwd({
|
||||
cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1752,7 +1720,7 @@ export class Session {
|
||||
break;
|
||||
|
||||
case "cancel_agent_request":
|
||||
await this.handleCancelAgentRequest(msg.agentId, msg.requestId);
|
||||
await this.handleCancelAgentRequest(msg.agentId);
|
||||
break;
|
||||
|
||||
case "restart_server_request":
|
||||
@@ -1820,6 +1788,18 @@ export class Session {
|
||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/subscribe_diff":
|
||||
await this.handleCheckoutSubscribeDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/get_file_hunks":
|
||||
await this.handleCheckoutGetFileHunksRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/unsubscribe_diff":
|
||||
this.handleCheckoutUnsubscribeDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout_switch_branch_request":
|
||||
await this.handleCheckoutSwitchBranchRequest(msg);
|
||||
break;
|
||||
@@ -1933,7 +1913,7 @@ export class Session {
|
||||
break;
|
||||
|
||||
case "clear_agent_attention":
|
||||
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
|
||||
await this.handleClearAgentAttention(msg.agentId);
|
||||
break;
|
||||
|
||||
case "client_heartbeat":
|
||||
@@ -3189,23 +3169,11 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
|
||||
private async handleCancelAgentRequest(agentId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
if (requestId) {
|
||||
const agent = this.agentManager.getAgent(agentId);
|
||||
const payload = agent ? await this.buildAgentPayload(agent) : null;
|
||||
this.emit({
|
||||
type: "cancel_agent_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
agent: payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request");
|
||||
}
|
||||
@@ -3221,7 +3189,6 @@ export class Session {
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
sessionLogger: this.sessionLogger,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
||||
},
|
||||
@@ -3869,32 +3836,11 @@ export class Session {
|
||||
/**
|
||||
* Handle clearing agent attention flag
|
||||
*/
|
||||
private async handleClearAgentAttention(
|
||||
agentId: string | string[],
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
|
||||
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
|
||||
|
||||
try {
|
||||
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
|
||||
if (requestId) {
|
||||
const agents = (
|
||||
await Promise.all(
|
||||
agentIds.map(async (id) => {
|
||||
const agent = this.agentManager.getAgent(id);
|
||||
return agent ? this.buildAgentPayload(agent) : null;
|
||||
}),
|
||||
)
|
||||
).filter((payload): payload is NonNullable<typeof payload> => payload !== null);
|
||||
this.emit({
|
||||
type: "clear_agent_attention_response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
agents,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention");
|
||||
// Don't throw - this is not critical
|
||||
@@ -4348,6 +4294,89 @@ export class Session {
|
||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private async handleCheckoutSubscribeDiffRequest(
|
||||
msg: CheckoutSubscribeDiffRequest,
|
||||
): Promise<void> {
|
||||
const cwd = expandTilde(msg.cwd);
|
||||
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
|
||||
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
|
||||
|
||||
const subscription = await this.checkoutDiffManager.subscribeLazy(
|
||||
{ cwd, compare: msg.compare },
|
||||
(snapshot) => {
|
||||
this.emit({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
...snapshot,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
|
||||
|
||||
this.emit({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
...subscription.initial,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise<void> {
|
||||
const cwd = expandTilde(msg.cwd);
|
||||
let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path);
|
||||
let error: {
|
||||
code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT";
|
||||
message: string;
|
||||
} | null = null;
|
||||
|
||||
if (hunks === null) {
|
||||
try {
|
||||
const file = await getCheckoutDiffFile(
|
||||
cwd,
|
||||
{
|
||||
mode: msg.compare.mode,
|
||||
baseRef: msg.compare.baseRef,
|
||||
ignoreWhitespace: msg.compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
},
|
||||
msg.path,
|
||||
{ paseoHome: this.paseoHome },
|
||||
);
|
||||
if (file) {
|
||||
hunks = file.hunks;
|
||||
} else {
|
||||
hunks = [];
|
||||
error = {
|
||||
code: "UNKNOWN",
|
||||
message: "File no longer present in diff",
|
||||
};
|
||||
}
|
||||
} catch (caughtError) {
|
||||
hunks = [];
|
||||
error = toCheckoutError(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
path: msg.path,
|
||||
hunks,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void {
|
||||
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
|
||||
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private async handleCheckoutSwitchBranchRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -4752,6 +4781,7 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
await this.workspaceGitService.refresh(cwd, { priority: "high" });
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||
this.emit({
|
||||
type: "checkout_pr_status_response",
|
||||
@@ -5476,27 +5506,26 @@ export class Session {
|
||||
projectRecord?: PersistedProjectRecord | null,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
let displayName = workspace.displayName;
|
||||
try {
|
||||
snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd: workspace.cwd },
|
||||
"Failed to load git snapshot for workspace",
|
||||
);
|
||||
return base;
|
||||
const placement = await this.buildProjectPlacement(workspace.cwd);
|
||||
displayName = deriveWorkspaceDisplayName({
|
||||
cwd: workspace.cwd,
|
||||
checkout: placement.checkout,
|
||||
});
|
||||
} catch {
|
||||
// Fall back to the persisted label if checkout metadata is unavailable.
|
||||
}
|
||||
|
||||
const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git);
|
||||
const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot | null = null;
|
||||
snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: displayName,
|
||||
diffStat: snapshot.git.diffStat ?? null,
|
||||
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot),
|
||||
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
|
||||
diffStat: snapshot?.git.diffStat ?? null,
|
||||
gitRuntime: snapshot ? this.buildWorkspaceGitRuntimePayload(snapshot) : undefined,
|
||||
githubRuntime: snapshot ? this.buildWorkspaceGitHubRuntimePayload(snapshot) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5809,9 +5838,7 @@ export class Session {
|
||||
const filter = request.filter;
|
||||
const sort = this.normalizeFetchWorkspacesSort(request.sort);
|
||||
let entries = await this.listWorkspaceDescriptors();
|
||||
const listedCount = entries.length;
|
||||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
|
||||
const filteredCount = entries.length;
|
||||
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
|
||||
|
||||
const cursorToken = request.page?.cursor;
|
||||
@@ -5830,21 +5857,6 @@ export class Session {
|
||||
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||||
: null;
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
filter: request.filter ?? null,
|
||||
sort,
|
||||
page: request.page ?? null,
|
||||
listedCount,
|
||||
filteredCount,
|
||||
returnedCount: pagedEntries.length,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
},
|
||||
"fetch_workspaces_entries_listed",
|
||||
);
|
||||
|
||||
return {
|
||||
entries: pagedEntries,
|
||||
pageInfo: {
|
||||
@@ -5923,6 +5935,8 @@ export class Session {
|
||||
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
|
||||
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
|
||||
projectRegistry: this.projectRegistry,
|
||||
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
|
||||
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
|
||||
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
|
||||
@@ -6125,16 +6139,6 @@ export class Session {
|
||||
: null;
|
||||
|
||||
try {
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscribeRequested: Boolean(request.subscribe),
|
||||
filter: request.filter ?? null,
|
||||
sort: request.sort ?? null,
|
||||
page: request.page ?? null,
|
||||
},
|
||||
"fetch_workspaces_request_received",
|
||||
);
|
||||
if (subscriptionId) {
|
||||
this.workspaceUpdatesSubscription = {
|
||||
subscriptionId,
|
||||
@@ -6146,15 +6150,6 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = await this.listFetchWorkspacesEntries(request);
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscriptionId,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeFetchWorkspacesEntries(payload.entries),
|
||||
},
|
||||
"fetch_workspaces_response_ready",
|
||||
);
|
||||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedLatestActivity = entry.activityAt
|
||||
@@ -6305,13 +6300,10 @@ export class Session {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||
emit: (message) => this.emit(message),
|
||||
registerPendingWorktreeWorkspace: (options) =>
|
||||
this.registerPendingWorktreeWorkspace(options),
|
||||
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
|
||||
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
|
||||
sessionLogger: this.sessionLogger,
|
||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
||||
},
|
||||
@@ -7495,6 +7487,11 @@ export class Session {
|
||||
}
|
||||
this.checkoutDiffSubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.checkoutDiffLazySubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -65,8 +65,6 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
peekSnapshot: ReturnType<typeof vi.fn>;
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
refresh: ReturnType<typeof vi.fn>;
|
||||
requestWorkingTreeWatch: ReturnType<typeof vi.fn>;
|
||||
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
subscriptions: Array<{
|
||||
@@ -107,11 +105,6 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||
getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)),
|
||||
refresh: vi.fn(async () => {}),
|
||||
requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({
|
||||
repoRoot: cwd,
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -335,17 +328,62 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => {
|
||||
test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => {
|
||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||
let refreshed = false;
|
||||
|
||||
workspaceGitService.refresh.mockImplementation(async () => {
|
||||
refreshed = true;
|
||||
});
|
||||
workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) =>
|
||||
createWorkspaceRuntimeSnapshot(cwd, {
|
||||
github: {
|
||||
pullRequest: refreshed
|
||||
? {
|
||||
url: "https://github.com/acme/repo/pull/457",
|
||||
title: "After explicit refresh",
|
||||
state: "merged",
|
||||
baseRefName: "main",
|
||||
headRefName: "workspace-git-service",
|
||||
isMerged: true,
|
||||
}
|
||||
: {
|
||||
url: "https://github.com/acme/repo/pull/456",
|
||||
title: "Before explicit refresh",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "workspace-git-service",
|
||||
isMerged: false,
|
||||
},
|
||||
refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout_pr_status_request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "req-pr-cached",
|
||||
requestId: "req-pr-refresh",
|
||||
});
|
||||
|
||||
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
|
||||
expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", {
|
||||
priority: "high",
|
||||
});
|
||||
expect(
|
||||
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
|
||||
).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
status: {
|
||||
url: "https://github.com/acme/repo/pull/457",
|
||||
title: "After explicit refresh",
|
||||
state: "merged",
|
||||
baseRefName: "main",
|
||||
headRefName: "workspace-git-service",
|
||||
isMerged: true,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "req-pr-refresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,11 +113,6 @@ function createNoopWorkspaceGitService() {
|
||||
},
|
||||
}),
|
||||
refresh: async () => {},
|
||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||
repoRoot: cwd,
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
@@ -1137,6 +1132,7 @@ describe("workspace aggregation", () => {
|
||||
|
||||
test("create paseo worktree request returns a registered workspace descriptor", async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
const paseoHome = path.join(tempDir, "paseo-home");
|
||||
@@ -1147,45 +1143,6 @@ describe("workspace aggregation", () => {
|
||||
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
||||
if (cwd === repoDir) {
|
||||
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: {
|
||||
repoRoot: repoDir,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cwd.includes("worktree-123")) {
|
||||
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: {
|
||||
repoRoot: cwd,
|
||||
currentBranch: "worktree-123",
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: {
|
||||
repoRoot: cwd,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
const session = createSessionForWorkspaceTests({
|
||||
workspaceGitService,
|
||||
}) as any;
|
||||
|
||||
const workspaces = new Map();
|
||||
const projects = new Map();
|
||||
@@ -1888,7 +1845,9 @@ describe("workspace aggregation", () => {
|
||||
});
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => {
|
||||
throw new Error("fetch_workspaces should not trigger per-workspace refreshes");
|
||||
});
|
||||
workspaceGitService.subscribe = vi.fn(async () => ({
|
||||
initial: runtimeSnapshot,
|
||||
unsubscribe: () => {},
|
||||
@@ -1942,7 +1901,8 @@ describe("workspace aggregation", () => {
|
||||
| { type: "fetch_workspaces_response"; payload: any }
|
||||
| undefined;
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(response?.payload.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "/tmp/repo",
|
||||
@@ -1993,7 +1953,9 @@ describe("workspace aggregation", () => {
|
||||
});
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => runtimeSnapshot);
|
||||
workspaceGitService.getSnapshot = vi.fn(async () => {
|
||||
throw new Error("workspace updates should use passive workspace git snapshots");
|
||||
});
|
||||
|
||||
const session = createSessionForWorkspaceTests({
|
||||
workspaceGitService,
|
||||
@@ -2046,7 +2008,8 @@ describe("workspace aggregation", () => {
|
||||
skipReconcile: true,
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo");
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(emitted).toContainEqual({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
|
||||
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
|
||||
listen: `${listenHost}:0`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: true,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
wrapSessionMessage,
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
|
||||
import type { HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostnameAllowed } from "./hostnames.js";
|
||||
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import { isHostAllowed } from "./allowed-hosts.js";
|
||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type {
|
||||
@@ -61,71 +61,11 @@ type PendingConnection = {
|
||||
|
||||
type WebSocketServerConfig = {
|
||||
allowedOrigins: Set<string>;
|
||||
hostnames?: HostnamesConfig;
|
||||
allowedHosts?: AllowedHostsConfig;
|
||||
};
|
||||
|
||||
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
||||
|
||||
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
|
||||
return {
|
||||
subscribe: async ({ cwd }: { cwd: string }) => ({
|
||||
initial: {
|
||||
cwd,
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
},
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getSnapshot: async (cwd: string) => ({
|
||||
cwd,
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
}),
|
||||
refresh: async () => {},
|
||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||
repoRoot: cwd,
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
dispose: () => {},
|
||||
} as unknown as WorkspaceGitServiceImpl;
|
||||
}
|
||||
|
||||
function createNoopProjectRegistry(): ProjectRegistry {
|
||||
return {
|
||||
initialize: async () => {},
|
||||
@@ -367,7 +307,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService?: LoopService,
|
||||
scheduleService?: ScheduleService,
|
||||
checkoutDiffManager?: CheckoutDiffManager,
|
||||
workspaceGitService?: WorkspaceGitServiceImpl,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -395,7 +334,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
||||
}
|
||||
this.checkoutDiffManager = checkoutDiffManager;
|
||||
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
|
||||
this.workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger: this.logger,
|
||||
paseoHome,
|
||||
});
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.daemonConfigStore = daemonConfigStore;
|
||||
@@ -433,7 +375,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.broadcastAgentAttention(params);
|
||||
});
|
||||
|
||||
const { allowedOrigins, hostnames } = wsConfig;
|
||||
const { allowedOrigins, allowedHosts } = wsConfig;
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
@@ -441,7 +383,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const requestMetadata = extractSocketRequestMetadata(req);
|
||||
const origin = requestMetadata.origin;
|
||||
const requestHost = requestMetadata.host ?? null;
|
||||
if (requestHost && !isHostnameAllowed(requestHost, hostnames)) {
|
||||
if (requestHost && !isHostAllowed(requestHost, allowedHosts)) {
|
||||
this.incrementRuntimeCounter("hostRejected");
|
||||
this.logger.warn(
|
||||
{ ...requestMetadata, host: requestHost },
|
||||
@@ -573,8 +515,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
this.providerSnapshotManager.destroy();
|
||||
this.checkoutDiffManager.dispose();
|
||||
this.workspaceGitService.dispose();
|
||||
this.checkoutDiffManager.dispose();
|
||||
this.pendingConnections.clear();
|
||||
this.sessions.clear();
|
||||
this.externalSessionsByKey.clear();
|
||||
@@ -858,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
lazyDiffRpcs: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
|
||||
import {
|
||||
WorkspaceGitServiceImpl,
|
||||
@@ -117,28 +116,11 @@ function createWatcher() {
|
||||
};
|
||||
}
|
||||
|
||||
function createDirent(name: string, isDirectory: boolean) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => isDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createService(options?: {
|
||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||
@@ -147,8 +129,6 @@ function createService(options?: {
|
||||
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
|
||||
hasOriginRemote?: ReturnType<typeof vi.fn>;
|
||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||
readdir?: ReturnType<typeof vi.fn>;
|
||||
watch?: ReturnType<typeof vi.fn>;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
@@ -157,7 +137,6 @@ function createService(options?: {
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
deps: {
|
||||
watch: options?.watch ?? ((() => createWatcher()) as unknown as any),
|
||||
readdir: options?.readdir ?? vi.fn(async () => []),
|
||||
getCheckoutStatus:
|
||||
options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||
getCheckoutShortstat:
|
||||
@@ -172,15 +151,6 @@ function createService(options?: {
|
||||
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
|
||||
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
|
||||
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
|
||||
runGitCommand:
|
||||
options?.runGitCommand ??
|
||||
vi.fn(async () => ({
|
||||
stdout: "/tmp/repo\n",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
})),
|
||||
now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")),
|
||||
},
|
||||
});
|
||||
@@ -247,48 +217,6 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
|
||||
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
getPullRequestStatus,
|
||||
resolveAbsoluteGitDir,
|
||||
});
|
||||
|
||||
const firstSnapshotPromise = service.getSnapshot("/tmp/repo");
|
||||
const secondSnapshotPromise = service.getSnapshot("/tmp/repo/.");
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||
expect((service as any).workspaceTargets.size).toBe(0);
|
||||
expect((service as any).workspaceTargetSetups.size).toBe(1);
|
||||
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
|
||||
await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([
|
||||
createSnapshot("/tmp/repo"),
|
||||
createSnapshot("/tmp/repo"),
|
||||
]);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).workspaceTargets.size).toBe(1);
|
||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||
|
||||
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||
@@ -504,172 +432,4 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watches nested repository directories on Linux", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "linux",
|
||||
});
|
||||
|
||||
const watchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||
const watch = vi.fn((watchPath: string) => {
|
||||
const watcher = createWatcher();
|
||||
watchCalls.push({ path: watchPath, close: watcher.close });
|
||||
return watcher as any;
|
||||
});
|
||||
const readdir = vi.fn(async (directory: string) => {
|
||||
if (directory === "/tmp/repo") {
|
||||
return [
|
||||
createDirent("packages", true),
|
||||
createDirent(".git", true),
|
||||
createDirent("README.md", false),
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages")) {
|
||||
return [createDirent("server", true), createDirent("app", true)];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||
return [createDirent("src", true)];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
||||
return [createDirent("server", true)];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const service = createService({ watch, readdir });
|
||||
const subscription = await service.requestWorkingTreeWatch(
|
||||
path.join("/tmp/repo", "packages", "server"),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(subscription.repoRoot).toBe("/tmp/repo");
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/.git",
|
||||
"/tmp/repo/packages",
|
||||
"/tmp/repo/packages/app",
|
||||
"/tmp/repo/packages/server",
|
||||
"/tmp/repo/packages/server/src",
|
||||
"/tmp/repo/packages/server/src/server",
|
||||
]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => {
|
||||
const watchers = [createWatcher(), createWatcher()];
|
||||
const watch = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(watchers[0] as any)
|
||||
.mockReturnValueOnce(watchers[1] as any);
|
||||
const service = createService({ watch });
|
||||
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener);
|
||||
const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener);
|
||||
|
||||
expect(first.repoRoot).toBe("/tmp/repo");
|
||||
expect(second.repoRoot).toBe("/tmp/repo");
|
||||
expect(watch).toHaveBeenCalledTimes(2);
|
||||
|
||||
first.unsubscribe();
|
||||
expect(watchers[0].close).not.toHaveBeenCalled();
|
||||
expect(watchers[1].close).not.toHaveBeenCalled();
|
||||
|
||||
second.unsubscribe();
|
||||
expect(watchers[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(watchers[1].close).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => {
|
||||
if (process.platform === "linux") {
|
||||
// On Linux, recursive watch is never attempted — the service uses per-directory
|
||||
// watchers from the start. This scenario only applies to macOS/Windows where
|
||||
// recursive watch is tried first and may fail.
|
||||
return;
|
||||
}
|
||||
|
||||
const recursiveUnsupported = new Error("recursive unsupported");
|
||||
const watch = vi
|
||||
.fn()
|
||||
.mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => {
|
||||
if (options.recursive) {
|
||||
throw recursiveUnsupported;
|
||||
}
|
||||
return createWatcher() as any;
|
||||
})
|
||||
.mockImplementationOnce(() => createWatcher() as any);
|
||||
|
||||
const service = createService({ watch });
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
|
||||
const target = (service as any).workingTreeWatchTargets.get("/tmp/repo");
|
||||
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("non-git directories fall back to watching cwd with polling", async () => {
|
||||
const watch = vi.fn(() => createWatcher() as any);
|
||||
const runGitCommand = vi.fn(async () => {
|
||||
throw new Error("not a git repository");
|
||||
});
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => null);
|
||||
const service = createService({
|
||||
watch,
|
||||
runGitCommand,
|
||||
resolveAbsoluteGitDir,
|
||||
});
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn());
|
||||
const target = (service as any).workingTreeWatchTargets.get("/tmp/plain");
|
||||
|
||||
expect(subscription.repoRoot).toBeNull();
|
||||
const expectedRecursive = process.platform !== "linux";
|
||||
expect(watch).toHaveBeenCalledWith(
|
||||
"/tmp/plain",
|
||||
{ recursive: expectedRecursive },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(target?.repoWatchPath).toBe("/tmp/plain");
|
||||
expect(target?.fallbackRefreshInterval).not.toBeNull();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("working tree changes notify listeners and schedule workspace refresh", async () => {
|
||||
const watchCallbacks: Array<() => void> = [];
|
||||
const watch = vi.fn(
|
||||
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
|
||||
watchCallbacks.push(callback);
|
||||
return createWatcher() as any;
|
||||
},
|
||||
);
|
||||
const service = createService({ watch });
|
||||
const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh");
|
||||
const listener = vi.fn();
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener);
|
||||
expect(watchCallbacks).toHaveLength(2);
|
||||
|
||||
watchCallbacks[0]?.();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo");
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
resolveAbsoluteGitDir,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||
|
||||
export type WorkspaceGitRuntimeSnapshot = {
|
||||
cwd: string;
|
||||
@@ -61,11 +59,6 @@ export interface WorkspaceGitService {
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
|
||||
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
|
||||
requestWorkingTreeWatch(
|
||||
cwd: string,
|
||||
onChange: () => void,
|
||||
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
|
||||
scheduleRefreshForCwd(cwd: string): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -73,7 +66,6 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo
|
||||
|
||||
interface WorkspaceGitServiceDependencies {
|
||||
watch: typeof watch;
|
||||
readdir: typeof readdir;
|
||||
getCheckoutStatus: typeof getCheckoutStatus;
|
||||
getCheckoutShortstat: typeof getCheckoutShortstat;
|
||||
getPullRequestStatus: typeof getPullRequestStatus;
|
||||
@@ -81,7 +73,6 @@ interface WorkspaceGitServiceDependencies {
|
||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||
runGitFetch: (cwd: string) => Promise<void>;
|
||||
runGitCommand: typeof runGitCommand;
|
||||
now: () => Date;
|
||||
}
|
||||
|
||||
@@ -111,18 +102,6 @@ interface RepoGitTarget {
|
||||
fetchInFlight: boolean;
|
||||
}
|
||||
|
||||
interface WorkingTreeWatchTarget {
|
||||
cwd: string;
|
||||
repoRoot: string | null;
|
||||
repoWatchPath: string | null;
|
||||
watchers: FSWatcher[];
|
||||
watchedPaths: Set<string>;
|
||||
fallbackRefreshInterval: NodeJS.Timeout | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | null;
|
||||
linuxTreeRefreshQueued: boolean;
|
||||
listeners: Set<() => void>;
|
||||
}
|
||||
|
||||
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
@@ -130,15 +109,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
||||
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
||||
|
||||
constructor(options: WorkspaceGitServiceOptions) {
|
||||
this.logger = options.logger.child({ module: "workspace-git-service" });
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.deps = {
|
||||
watch: options.deps?.watch ?? watch,
|
||||
readdir: options.deps?.readdir ?? readdir,
|
||||
watch,
|
||||
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
|
||||
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
|
||||
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
|
||||
@@ -146,7 +122,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
|
||||
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
|
||||
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
|
||||
runGitCommand: options.deps?.runGitCommand ?? runGitCommand,
|
||||
now: options.deps?.now ?? (() => new Date()),
|
||||
};
|
||||
}
|
||||
@@ -176,9 +151,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (target?.latestSnapshot) {
|
||||
return target.latestSnapshot;
|
||||
}
|
||||
|
||||
const ensuredTarget = await this.ensureWorkspaceTarget(cwd);
|
||||
return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd));
|
||||
return this.refreshSnapshot(cwd);
|
||||
}
|
||||
|
||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||
@@ -197,47 +170,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
await this.ensureWorkspaceTarget(cwd);
|
||||
}
|
||||
|
||||
async requestWorkingTreeWatch(
|
||||
cwd: string,
|
||||
onChange: () => void,
|
||||
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
const target = await this.ensureWorkingTreeWatchTarget(cwd);
|
||||
target.listeners.add(onChange);
|
||||
|
||||
return {
|
||||
repoRoot: target.repoRoot,
|
||||
unsubscribe: () => {
|
||||
this.removeWorkingTreeWatchListener(cwd, onChange);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): void {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
const target = this.workspaceTargets.get(cwd);
|
||||
if (target) {
|
||||
this.scheduleWorkspaceRefresh(target);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const target of this.workspaceTargets.values()) {
|
||||
this.closeWorkspaceTarget(target);
|
||||
}
|
||||
this.workspaceTargets.clear();
|
||||
this.workspaceTargetSetups.clear();
|
||||
|
||||
for (const target of this.repoTargets.values()) {
|
||||
this.closeRepoTarget(target);
|
||||
}
|
||||
this.repoTargets.clear();
|
||||
|
||||
for (const target of this.workingTreeWatchTargets.values()) {
|
||||
this.closeWorkingTreeWatchTarget(target);
|
||||
}
|
||||
this.workingTreeWatchTargets.clear();
|
||||
this.workingTreeWatchSetups.clear();
|
||||
this.workspaceTargetSetups.clear();
|
||||
}
|
||||
|
||||
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
||||
@@ -258,24 +201,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return setup;
|
||||
}
|
||||
|
||||
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||
const existingTarget = this.workingTreeWatchTargets.get(cwd);
|
||||
if (existingTarget) {
|
||||
return existingTarget;
|
||||
}
|
||||
|
||||
const existingSetup = this.workingTreeWatchSetups.get(cwd);
|
||||
if (existingSetup) {
|
||||
return existingSetup;
|
||||
}
|
||||
|
||||
const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => {
|
||||
this.workingTreeWatchSetups.delete(cwd);
|
||||
});
|
||||
this.workingTreeWatchSetups.set(cwd, setup);
|
||||
return setup;
|
||||
}
|
||||
|
||||
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
|
||||
const target: WorkspaceGitTarget = {
|
||||
cwd,
|
||||
@@ -305,83 +230,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return target;
|
||||
}
|
||||
|
||||
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||
const target: WorkingTreeWatchTarget = {
|
||||
cwd,
|
||||
repoRoot,
|
||||
repoWatchPath: null,
|
||||
watchers: [],
|
||||
watchedPaths: new Set<string>(),
|
||||
fallbackRefreshInterval: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
linuxTreeRefreshQueued: false,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
const repoWatchPath = repoRoot ?? cwd;
|
||||
target.repoWatchPath = repoWatchPath;
|
||||
const watchPaths = new Set<string>([repoWatchPath]);
|
||||
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
|
||||
if (gitDir) {
|
||||
watchPaths.add(gitDir);
|
||||
}
|
||||
|
||||
let hasRecursiveRepoCoverage = false;
|
||||
const allowRecursiveRepoWatch = process.platform !== "linux";
|
||||
if (process.platform === "linux") {
|
||||
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
||||
}
|
||||
for (const watchPath of watchPaths) {
|
||||
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
||||
continue;
|
||||
}
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive);
|
||||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||||
hasRecursiveRepoCoverage = true;
|
||||
}
|
||||
}
|
||||
|
||||
const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage;
|
||||
if (target.watchers.length === 0 || missingRepoCoverage) {
|
||||
target.fallbackRefreshInterval = setInterval(() => {
|
||||
this.scheduleWorkspaceRefresh(cwd);
|
||||
for (const listener of target.listeners) {
|
||||
listener();
|
||||
}
|
||||
}, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS);
|
||||
this.logger.warn(
|
||||
{
|
||||
cwd,
|
||||
intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS,
|
||||
reason:
|
||||
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
|
||||
},
|
||||
"Working tree watchers unavailable; using timed refresh fallback",
|
||||
);
|
||||
}
|
||||
|
||||
this.workingTreeWatchTargets.set(cwd, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await this.deps.runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const root = stdout.trim();
|
||||
return root.length > 0 ? root : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
|
||||
@@ -460,15 +308,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
void this.runRepoFetch(repoTarget);
|
||||
}
|
||||
|
||||
private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void {
|
||||
const target =
|
||||
typeof targetOrCwd === "string"
|
||||
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
|
||||
: targetOrCwd;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void {
|
||||
if (target.debounceTimer) {
|
||||
clearTimeout(target.debounceTimer);
|
||||
}
|
||||
@@ -479,149 +319,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private addWorkingTreeWatcher(
|
||||
target: WorkingTreeWatchTarget,
|
||||
watchPath: string,
|
||||
shouldTryRecursive: boolean,
|
||||
): boolean {
|
||||
if (target.watchedPaths.has(watchPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { cwd } = target;
|
||||
const onChange = () => {
|
||||
if (process.platform === "linux" && target.repoWatchPath) {
|
||||
void this.refreshLinuxRepoTreeWatchers(target);
|
||||
}
|
||||
this.scheduleWorkspaceRefresh(cwd);
|
||||
for (const listener of target.listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
this.deps.watch(watchPath, { recursive }, () => {
|
||||
onChange();
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd },
|
||||
"Working tree recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd },
|
||||
"Failed to start working tree watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher");
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
target.watchedPaths.add(watchPath);
|
||||
return watcherIsRecursive;
|
||||
}
|
||||
|
||||
private async ensureLinuxRepoTreeWatchers(
|
||||
target: WorkingTreeWatchTarget,
|
||||
rootPath: string,
|
||||
): Promise<boolean> {
|
||||
const directories = await this.listLinuxWatchDirectories(rootPath);
|
||||
let complete = true;
|
||||
for (const directory of directories) {
|
||||
const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false);
|
||||
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return complete && target.watchedPaths.has(rootPath);
|
||||
}
|
||||
|
||||
private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise<void> {
|
||||
if (process.platform !== "linux" || !target.repoWatchPath) {
|
||||
return;
|
||||
}
|
||||
const rootPath = target.repoWatchPath;
|
||||
if (target.linuxTreeRefreshPromise) {
|
||||
target.linuxTreeRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
target.linuxTreeRefreshPromise = (async () => {
|
||||
do {
|
||||
target.linuxTreeRefreshQueued = false;
|
||||
try {
|
||||
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
cwd: target.cwd,
|
||||
rootPath,
|
||||
},
|
||||
"Failed to refresh Linux working tree watchers",
|
||||
);
|
||||
}
|
||||
} while (target.linuxTreeRefreshQueued);
|
||||
})();
|
||||
|
||||
try {
|
||||
await target.linuxTreeRefreshPromise;
|
||||
} finally {
|
||||
target.linuxTreeRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||
const directories: string[] = [];
|
||||
const pending = [rootPath];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
directories.push(directory);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await this.deps.readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
|
||||
if (target.refreshPromise) {
|
||||
target.refreshQueued = true;
|
||||
@@ -738,21 +435,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workspaceTargets.delete(target.cwd);
|
||||
}
|
||||
|
||||
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||
const target = this.workingTreeWatchTargets.get(cwd);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.listeners.delete(listener);
|
||||
if (target.listeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeWorkingTreeWatchTarget(target);
|
||||
this.workingTreeWatchTargets.delete(cwd);
|
||||
}
|
||||
|
||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||
if (target.debounceTimer) {
|
||||
clearTimeout(target.debounceTimer);
|
||||
@@ -766,20 +448,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||
if (target.fallbackRefreshInterval) {
|
||||
clearInterval(target.fallbackRefreshInterval);
|
||||
target.fallbackRefreshInterval = null;
|
||||
}
|
||||
|
||||
for (const watcher of target.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
private closeRepoTarget(target: RepoGitTarget): void {
|
||||
if (target.intervalId) {
|
||||
clearInterval(target.intervalId);
|
||||
|
||||
@@ -6,77 +6,15 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
|
||||
function createNoopWorkspaceGitService(): WorkspaceGitService {
|
||||
return {
|
||||
subscribe: async (params) => ({
|
||||
initial: {
|
||||
cwd: params.cwd,
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
},
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getSnapshot: async (cwd) => ({
|
||||
cwd,
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
}),
|
||||
refresh: async () => {},
|
||||
requestWorkingTreeWatch: async () => ({
|
||||
repoRoot: null,
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("bootstrapWorkspaceRegistries", () => {
|
||||
let tmpDir: string;
|
||||
let paseoHome: string;
|
||||
let agentStorage: AgentStorage;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
let workspaceGitService: WorkspaceGitService;
|
||||
const logger = createTestLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -91,7 +29,6 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
path.join(paseoHome, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
workspaceGitService = createNoopWorkspaceGitService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,7 +94,6 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -221,7 +157,6 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
@@ -54,7 +53,6 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
agentStorage: AgentStorage;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
logger: Logger;
|
||||
}): Promise<void> {
|
||||
const [projectsExists, workspacesExists] = await Promise.all([
|
||||
@@ -81,7 +79,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
const normalizedCwd = normalizeWorkspaceId(record.cwd);
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: normalizedCwd,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
|
||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
export type PersistedProjectKind = "git" | "non_git";
|
||||
@@ -137,50 +137,6 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi
|
||||
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
|
||||
}
|
||||
|
||||
export function checkoutLiteFromGitSnapshot(
|
||||
cwd: string,
|
||||
git: {
|
||||
isGit: boolean;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
repoRoot: string | null;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
mainRepoRoot: string | null;
|
||||
},
|
||||
): ProjectCheckoutLitePayload {
|
||||
if (!git.isGit) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
if (git.isPaseoOwnedWorktree && git.mainRepoRoot) {
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: git.currentBranch,
|
||||
remoteUrl: git.remoteUrl,
|
||||
worktreeRoot: git.repoRoot ?? cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: git.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: git.currentBranch,
|
||||
remoteUrl: git.remoteUrl,
|
||||
worktreeRoot: git.repoRoot ?? cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectStaleWorkspaces(
|
||||
input: DetectStaleWorkspacesInput,
|
||||
): Promise<Set<string>> {
|
||||
@@ -198,15 +154,45 @@ export async function detectStaleWorkspaces(
|
||||
|
||||
export async function buildProjectPlacementForCwd(input: {
|
||||
cwd: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
paseoHome: string;
|
||||
}): Promise<ProjectPlacementPayload> {
|
||||
const normalizedCwd = normalizeWorkspaceId(input.cwd);
|
||||
const checkout = await input.workspaceGitService
|
||||
.getSnapshot(normalizedCwd)
|
||||
.then(
|
||||
(snapshot): ProjectCheckoutLitePayload =>
|
||||
checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git),
|
||||
)
|
||||
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
|
||||
.then((status): ProjectCheckoutLitePayload => {
|
||||
if (!status.isGit) {
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
worktreeRoot: status.worktreeRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
worktreeRoot: status.worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
})
|
||||
.catch(
|
||||
(): ProjectCheckoutLitePayload => ({
|
||||
cwd: normalizedCwd,
|
||||
|
||||
@@ -17,11 +17,10 @@ import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import { createAgentWorktree } from "./worktree-bootstrap.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { getCurrentBranch, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
||||
import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
computeWorktreePath,
|
||||
@@ -35,7 +34,8 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
|
||||
export type NormalizedGitOptions = {
|
||||
@@ -51,7 +51,6 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
type BuildAgentSessionConfigDependencies = {
|
||||
paseoHome?: string;
|
||||
sessionLogger: Logger;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
|
||||
createBranchFromBase: (params: {
|
||||
cwd: string;
|
||||
@@ -86,6 +85,7 @@ type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||
}) => PersistedWorkspaceRecord;
|
||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
|
||||
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
|
||||
};
|
||||
@@ -99,7 +99,6 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
paseoHome?: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
describeWorkspaceRecord: (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
@@ -109,7 +108,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}) => Promise<PersistedWorkspaceRecord>;
|
||||
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
|
||||
sessionLogger: Logger;
|
||||
runWorktreeSetupInBackground: (options: {
|
||||
requestCwd: string;
|
||||
@@ -152,7 +150,11 @@ export async function buildAgentSessionConfig(
|
||||
if (normalized.createNewBranch) {
|
||||
targetBranch = normalized.newBranchName!;
|
||||
} else {
|
||||
targetBranch = (await getCurrentBranch(cwd)) ?? "";
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
targetBranch = stdout.trim();
|
||||
}
|
||||
|
||||
if (!targetBranch) {
|
||||
@@ -165,8 +167,7 @@ export async function buildAgentSessionConfig(
|
||||
);
|
||||
|
||||
const baseBranch =
|
||||
normalized.baseBranch ??
|
||||
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
@@ -178,8 +179,7 @@ export async function buildAgentSessionConfig(
|
||||
worktreeConfig = createdWorktree;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ??
|
||||
(await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService));
|
||||
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
|
||||
await dependencies.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch,
|
||||
@@ -264,18 +264,13 @@ export function assertSafeGitRef(ref: string, label: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
workspaceGitService: WorkspaceGitService,
|
||||
): Promise<string> {
|
||||
const snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||
if (!snapshot.git.isGit) {
|
||||
export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Cannot create a worktree outside a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
|
||||
: (snapshot.git.repoRoot ?? cwd);
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
@@ -530,6 +525,9 @@ export async function registerPendingWorktreeWorkspace(
|
||||
|
||||
await dependencies.projectRegistry.upsert(nextProjectRecord);
|
||||
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
@@ -547,14 +545,14 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
|
||||
if (!snapshot.git.isGit) {
|
||||
const checkout = await getCheckoutStatusLite(request.cwd, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Create worktree requires a git repository");
|
||||
}
|
||||
|
||||
const repoRoot = snapshot.git.isPaseoOwnedWorktree
|
||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
|
||||
: (snapshot.git.repoRoot ?? request.cwd);
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
@@ -585,8 +583,6 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
await dependencies.syncWorkspaceGitWatchTarget(worktreePath, { isGit: true });
|
||||
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
|
||||
@@ -47,6 +47,13 @@ import {
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
} from "../server/loop/rpc-schemas.js";
|
||||
import {
|
||||
CheckoutDiffSnapshotSchema,
|
||||
CheckoutFileHunksResponseSchema,
|
||||
CheckoutGetFileHunksRequestSchema,
|
||||
CheckoutSubscribeDiffRequestSchema,
|
||||
CheckoutUnsubscribeDiffRequestSchema,
|
||||
} from "../server/checkout/rpc-schemas.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutable daemon config schemas (shared between server store and client)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -876,7 +883,6 @@ export const RefreshAgentRequestMessageSchema = z.object({
|
||||
export const CancelAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("cancel_agent_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RestartServerRequestMessageSchema = z.object({
|
||||
@@ -1014,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
|
||||
"UNKNOWN",
|
||||
]);
|
||||
|
||||
const CheckoutErrorSchema = z.object({
|
||||
export const CheckoutErrorSchema = z.object({
|
||||
code: CheckoutErrorCodeSchema,
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutDiffCompareSchema = z.object({
|
||||
export const CheckoutDiffCompareSchema = z.object({
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
@@ -1232,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
|
||||
// Highlighted diff token schema
|
||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
||||
const HighlightTokenSchema = z.object({
|
||||
export const HighlightTokenSchema = z.object({
|
||||
text: z.string(),
|
||||
style: z.string().nullable(),
|
||||
});
|
||||
|
||||
const DiffLineSchema = z.object({
|
||||
export const DiffLineSchema = z.object({
|
||||
type: z.enum(["add", "remove", "context", "header"]),
|
||||
content: z.string(),
|
||||
tokens: z.array(HighlightTokenSchema).optional(),
|
||||
});
|
||||
|
||||
const DiffHunkSchema = z.object({
|
||||
export const DiffHunkSchema = z.object({
|
||||
oldStart: z.number(),
|
||||
oldCount: z.number(),
|
||||
newStart: z.number(),
|
||||
@@ -1251,7 +1257,7 @@ const DiffHunkSchema = z.object({
|
||||
lines: z.array(DiffLineSchema),
|
||||
});
|
||||
|
||||
const ParsedDiffFileSchema = z.object({
|
||||
export const ParsedDiffFileSchema = z.object({
|
||||
path: z.string(),
|
||||
isNew: z.boolean(),
|
||||
isDeleted: z.boolean(),
|
||||
@@ -1308,7 +1314,6 @@ export const FileDownloadTokenRequestSchema = z.object({
|
||||
export const ClearAgentAttentionMessageSchema = z.object({
|
||||
type: z.literal("clear_agent_attention"),
|
||||
agentId: z.union([z.string(), z.array(z.string())]),
|
||||
requestId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ClientHeartbeatMessageSchema = z.object({
|
||||
@@ -1466,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
CheckoutSubscribeDiffRequestSchema,
|
||||
CheckoutUnsubscribeDiffRequestSchema,
|
||||
CheckoutGetFileHunksRequestSchema,
|
||||
CheckoutCommitRequestSchema,
|
||||
CheckoutMergeRequestSchema,
|
||||
CheckoutMergeFromBaseRequestSchema,
|
||||
@@ -1688,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
features: z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
lazyDiffRpcs: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2104,24 +2113,6 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CancelAgentResponseMessageSchema = z.object({
|
||||
type: z.literal("cancel_agent_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ClearAgentAttentionResponseMessageSchema = z.object({
|
||||
type: z.literal("clear_agent_attention_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string().or(z.array(z.string())),
|
||||
agents: z.array(AgentSnapshotPayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SendAgentMessageResponseMessageSchema = z.object({
|
||||
type: z.literal("send_agent_message_response"),
|
||||
payload: z.object({
|
||||
@@ -2791,8 +2782,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
CancelAgentResponseMessageSchema,
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
SendAgentMessageResponseMessageSchema,
|
||||
SetVoiceModeResponseMessageSchema,
|
||||
GetDaemonConfigResponseMessageSchema,
|
||||
@@ -2811,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
CheckoutDiffSnapshotSchema,
|
||||
CheckoutFileHunksResponseSchema,
|
||||
CheckoutCommitResponseSchema,
|
||||
CheckoutMergeResponseSchema,
|
||||
CheckoutMergeFromBaseResponseSchema,
|
||||
@@ -2905,7 +2896,6 @@ export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessage
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
typeof FetchAgentTimelineResponseMessageSchema
|
||||
>;
|
||||
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
||||
export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMessageSchema>;
|
||||
@@ -3022,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
||||
export type CheckoutSubscribeDiffRequest = z.infer<typeof CheckoutSubscribeDiffRequestSchema>;
|
||||
export type CheckoutUnsubscribeDiffRequest = z.infer<typeof CheckoutUnsubscribeDiffRequestSchema>;
|
||||
export type CheckoutGetFileHunksRequest = z.infer<typeof CheckoutGetFileHunksRequestSchema>;
|
||||
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
|
||||
export type CheckoutDiffSnapshot = z.infer<typeof CheckoutDiffSnapshotSchema>;
|
||||
export type CheckoutFileHunksResponse = z.infer<typeof CheckoutFileHunksResponseSchema>;
|
||||
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
|
||||
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
|
||||
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
||||
@@ -3071,9 +3066,6 @@ export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponse
|
||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>;
|
||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||
export type ClearAgentAttentionResponseMessage = z.infer<
|
||||
typeof ClearAgentAttentionResponseMessageSchema
|
||||
>;
|
||||
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
|
||||
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
|
||||
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
__setGhPathForTests,
|
||||
__setPullRequestStatusCacheTtlForTests,
|
||||
commitAll,
|
||||
getCurrentBranch,
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
@@ -85,15 +85,6 @@ describe("checkout git utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for getCurrentBranch in a repo with no commits", async () => {
|
||||
const emptyRepo = join(tempDir, "empty-repo");
|
||||
execSync(`mkdir -p ${emptyRepo}`);
|
||||
execSync("git init -b main", { cwd: emptyRepo });
|
||||
|
||||
const branch = await getCurrentBranch(emptyRepo);
|
||||
expect(branch).toBeNull();
|
||||
});
|
||||
|
||||
it("handles status/diff/commit in a normal repo", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
||||
|
||||
@@ -173,6 +164,15 @@ const x = 1;
|
||||
expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]);
|
||||
});
|
||||
|
||||
it("returns lightweight checkout status for normal repos", async () => {
|
||||
const status = await getCheckoutStatusLite(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
expect(status.worktreeRoot).toBe(repoDir);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||
expect(status.mainRepoRoot).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes hasRemote when origin is configured", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
@@ -380,6 +380,22 @@ const x = 1;
|
||||
expect(message).toBe("worktree update");
|
||||
});
|
||||
|
||||
it("returns lightweight checkout status for .paseo worktrees", async () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "lite-alpha",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.worktreeRoot).toBe(result.worktreePath);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
});
|
||||
|
||||
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
||||
const bareRepoDir = join(tempDir, "bare-repo");
|
||||
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
||||
|
||||
@@ -482,6 +482,38 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas
|
||||
|
||||
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||
|
||||
export type CheckoutStatusLiteNotGit = {
|
||||
isGit: false;
|
||||
currentBranch: null;
|
||||
remoteUrl: null;
|
||||
worktreeRoot: null;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteGitNonPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string;
|
||||
isPaseoOwnedWorktree: false;
|
||||
mainRepoRoot: null;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteGitPaseo = {
|
||||
isGit: true;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
worktreeRoot: string;
|
||||
isPaseoOwnedWorktree: true;
|
||||
mainRepoRoot: string;
|
||||
};
|
||||
|
||||
export type CheckoutStatusLiteResult =
|
||||
| CheckoutStatusLiteNotGit
|
||||
| CheckoutStatusLiteGitNonPaseo
|
||||
| CheckoutStatusLiteGitPaseo;
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
diff: string;
|
||||
structured?: ParsedDiffFile[];
|
||||
@@ -525,16 +557,12 @@ async function requireGitRepo(cwd: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const branch = stdout.trim();
|
||||
return branch.length > 0 ? branch : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const branch = stdout.trim();
|
||||
return branch.length > 0 ? branch : null;
|
||||
}
|
||||
|
||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||
@@ -1128,6 +1156,43 @@ export async function getCheckoutStatus(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutStatusLite(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutStatusLiteResult> {
|
||||
const inspected = await inspectCheckoutContext(cwd, context);
|
||||
if (!inspected) {
|
||||
return {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (inspected.configured.isPaseoOwnedWorktree) {
|
||||
return {
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
worktreeRoot: inspected.worktreeRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: await getMainRepoRoot(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isGit: true,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
worktreeRoot: inspected.worktreeRoot,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckoutShortstat {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
@@ -1212,9 +1277,10 @@ export async function getCheckoutShortstat(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
async function getCheckoutDiffInternal(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
options?: { path?: string },
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
await requireGitRepo(cwd);
|
||||
@@ -1240,7 +1306,12 @@ export async function getCheckoutDiff(
|
||||
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
||||
changes.sort((a, b) => {
|
||||
const targetPath = options?.path;
|
||||
const filteredChanges =
|
||||
typeof targetPath === "string"
|
||||
? changes.filter((change) => change.path === targetPath)
|
||||
: changes;
|
||||
filteredChanges.sort((a, b) => {
|
||||
if (a.path === b.path) return 0;
|
||||
return a.path < b.path ? -1 : 1;
|
||||
});
|
||||
@@ -1264,8 +1335,8 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
};
|
||||
|
||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
||||
const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
|
||||
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
|
||||
|
||||
const trackedNumstatByPath =
|
||||
@@ -1449,6 +1520,24 @@ export async function getCheckoutDiff(
|
||||
return { diff: diffText };
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
return getCheckoutDiffInternal(cwd, compare, undefined, context);
|
||||
}
|
||||
|
||||
export async function getCheckoutDiffFile(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
path: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<ParsedDiffFile | null> {
|
||||
const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
|
||||
return result.structured?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
cwd: string,
|
||||
options: { message: string; addAll?: boolean },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user