mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb8506d1f | ||
|
|
ab24070075 | ||
|
|
fab975a059 | ||
|
|
504b687f89 | ||
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 | ||
|
|
76e336a1be | ||
|
|
f0d7eeb98c | ||
|
|
f91a984348 | ||
|
|
cbbf6c1684 | ||
|
|
89c2fac3c6 | ||
|
|
f8dd0fc2e0 | ||
|
|
b55acfc60f | ||
|
|
a6fdcb469c | ||
|
|
5bd317205c | ||
|
|
e59c94812b | ||
|
|
1c8fabd293 | ||
|
|
717f195f0c | ||
|
|
869edcbf11 | ||
|
|
c596e058cd | ||
|
|
fa1198c2be | ||
|
|
1f253d92e2 |
233
.github/workflows/ci.yml
vendored
233
.github/workflows/ci.yml
vendored
@@ -18,6 +18,106 @@ env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
|
||||
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
|
||||
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
|
||||
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
|
||||
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
|
||||
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
|
||||
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
|
||||
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
|
||||
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect affected CI jobs
|
||||
id: filter
|
||||
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
|
||||
with:
|
||||
filters: |
|
||||
shared:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
- '.mise.toml'
|
||||
- '.tool-versions'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- 'scripts/**'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.base.json'
|
||||
- 'vitest.config.ts'
|
||||
quality:
|
||||
- 'packages/**'
|
||||
- '*.cjs'
|
||||
- '*.js'
|
||||
- '*.json'
|
||||
- '*.mjs'
|
||||
- '*.ts'
|
||||
server:
|
||||
- 'packages/app/e2e/fixtures/recording.*'
|
||||
- 'packages/client/**'
|
||||
- 'packages/cli/src/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop:
|
||||
- 'packages/app/**'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
desktop_package:
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'packages/desktop/**'
|
||||
app:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
sdk:
|
||||
- 'packages/client/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
playwright:
|
||||
- 'packages/app/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/expo-two-way-audio/**'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
relay:
|
||||
- 'packages/relay/**'
|
||||
cli:
|
||||
- 'nix/**'
|
||||
- 'packages/app/e2e/global-setup.ts'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/client/**'
|
||||
- 'packages/desktop/src/daemon/runtime-paths.ts'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
|
||||
- name: Validate CI workflow
|
||||
run: node --test scripts/ci-workflow.test.mjs
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -39,6 +139,12 @@ jobs:
|
||||
run: npx oxfmt --check .
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -63,6 +169,12 @@ jobs:
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -89,6 +201,8 @@ jobs:
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -97,28 +211,43 @@ jobs:
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected server tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No server changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -126,6 +255,8 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -134,49 +265,53 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Skip unaffected desktop tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No desktop changes detected."
|
||||
|
||||
- name: Detect desktop changes
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
id: desktop_changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
desktop:
|
||||
- 'packages/desktop/**'
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Run real Electron browser tab bridge E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
|
||||
- name: Upload browser tab bridge diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure() && matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
@@ -184,7 +319,11 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
@@ -192,7 +331,11 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||
if: >-
|
||||
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
@@ -201,6 +344,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -225,6 +374,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -251,6 +406,8 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -264,43 +421,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected Playwright tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No Playwright changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
if: env.RUN_TESTS == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && matrix.desktop
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: env.RUN_TESTS == 'true' && failure()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
@@ -309,6 +480,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -330,6 +507,8 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -338,24 +517,38 @@ jobs:
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected CLI tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No CLI changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
## ライセンス
|
||||
|
||||
@@ -163,6 +163,7 @@ npm run typecheck
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
## License
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
@@ -50,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
|
||||
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
|
||||
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
|
||||
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
|
||||
destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
|
||||
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
|
||||
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
|
||||
or permission, and have not been activated during the idle window are eligible. `running`,
|
||||
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
|
||||
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
|
||||
independently; collection does not cascade or change parentage.
|
||||
|
||||
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
|
||||
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
|
||||
runtime.
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
|
||||
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
|
||||
releases the live runtime and writes `lastStatus: closed` on the still-active record.
|
||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
||||
children.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
@@ -242,6 +243,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
Because the shell differs per platform, a lifecycle command that must run
|
||||
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
|
||||
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
|
||||
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
|
||||
that reads what it needs from `process.env` and invoke it as
|
||||
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
|
||||
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
|
||||
@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ There are two supported ways to ship from `main`:
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
|
||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
||||
a beta is promoted or superseded by a direct stable release.
|
||||
|
||||
## Release version decision
|
||||
|
||||
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
|
||||
@@ -76,6 +84,20 @@ npm run release:minor
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
|
||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
||||
stable release complete.
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
|
||||
@@ -92,6 +114,7 @@ npm run version:all:patch
|
||||
npm run version:all:minor
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY=
|
||||
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client": "npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -177,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await expect(searchInput).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
projectDisplayName: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
openNewWorkspaceProjectPickerWithShortcut,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
type ScrollKeyboardDismissEvent,
|
||||
type ScrollKeyboardDismissGesture,
|
||||
} from "./model";
|
||||
|
||||
interface Point {
|
||||
ts: number;
|
||||
y: number;
|
||||
nativeTs?: number | null;
|
||||
}
|
||||
|
||||
function event(point: Point): ScrollKeyboardDismissEvent {
|
||||
return {
|
||||
timeStamp: point.ts,
|
||||
nativeEvent: {
|
||||
contentOffset: { y: point.y },
|
||||
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
|
||||
return points.reduce(
|
||||
(gesture, point) => recordScroll(gesture, event(point)),
|
||||
beginDrag(event(start)),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
|
||||
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
|
||||
}
|
||||
|
||||
describe("scroll keyboard dismissal", () => {
|
||||
it("keeps the keyboard up for a slow read-scroll", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 8 },
|
||||
{ ts: 1080, y: 16 },
|
||||
{ ts: 1120, y: 24 },
|
||||
],
|
||||
{ ts: 1160, y: 32 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses for an upward flick", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a fast-but-decelerating drag below the release threshold", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1100, y: 500 },
|
||||
{ ts: 1200, y: 590 },
|
||||
{ ts: 1270, y: 598 },
|
||||
],
|
||||
{ ts: 1300, y: 600 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the whole drag when the gesture is too short to sample", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("steps back a sample when release lands immediately after one", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 120 },
|
||||
{ ts: 1075, y: 225 },
|
||||
],
|
||||
{ ts: 1080, y: 240 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when release has no measurable time span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scroll events that arrive before the minimum sample span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 5000, nativeTs: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 5001, nativeTs: 1200, y: 40 },
|
||||
{ ts: 5002, nativeTs: 1400, y: 80 },
|
||||
],
|
||||
{ ts: 5003, nativeTs: 1600, y: 120 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to synthetic event time when native time is absent", () => {
|
||||
expect(
|
||||
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
|
||||
ts: 1080,
|
||||
nativeTs: null,
|
||||
y: 240,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll and release events without a matching drag", () => {
|
||||
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
|
||||
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to idle after release", () => {
|
||||
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
|
||||
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
});
|
||||
});
|
||||
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pure state machine for the chat history's flick-to-dismiss gesture.
|
||||
*
|
||||
* Native keyboardDismissMode cannot express this interaction: "on-drag"
|
||||
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
|
||||
* inverted list. We therefore classify the list's existing scroll gesture at
|
||||
* release instead of introducing a competing pan recognizer.
|
||||
*/
|
||||
|
||||
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
|
||||
const RELEASE_SAMPLE_MIN_MS = 30;
|
||||
|
||||
/**
|
||||
* The subset of a native scroll event used by the classifier. React Native's
|
||||
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
|
||||
*/
|
||||
export interface ScrollKeyboardDismissEvent {
|
||||
timeStamp: number;
|
||||
nativeEvent: {
|
||||
contentOffset: { y: number };
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DragSamples {
|
||||
startTs: number;
|
||||
startY: number;
|
||||
sampleTs: number;
|
||||
sampleY: number;
|
||||
previousSampleTs: number;
|
||||
previousSampleY: number;
|
||||
}
|
||||
|
||||
export type ScrollKeyboardDismissGesture =
|
||||
| { phase: "idle" }
|
||||
| { phase: "dragging"; samples: DragSamples };
|
||||
|
||||
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
|
||||
const nativeTimestamp = event.nativeEvent.timestamp;
|
||||
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
|
||||
return nativeTimestamp;
|
||||
}
|
||||
return event.timeStamp;
|
||||
}
|
||||
|
||||
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: timestamp,
|
||||
startY: offsetY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: offsetY,
|
||||
previousSampleTs: 0,
|
||||
previousSampleY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordScroll(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): ScrollKeyboardDismissGesture {
|
||||
if (gesture.phase === "idle") {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const { samples } = gesture;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: samples.startTs,
|
||||
startY: samples.startY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: event.nativeEvent.contentOffset.y,
|
||||
previousSampleTs: samples.sampleTs,
|
||||
previousSampleY: samples.sampleY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseDrag(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
|
||||
if (gesture.phase === "idle") {
|
||||
return { gesture, shouldDismiss: false };
|
||||
}
|
||||
|
||||
const releaseTs = resolveEventTimeMs(event);
|
||||
const releaseY = event.nativeEvent.contentOffset.y;
|
||||
const { samples } = gesture;
|
||||
|
||||
// The release event carries the gesture's true endpoint. The final onScroll
|
||||
// event can still be stale when a short flick lands.
|
||||
let spanStartTs = samples.startTs;
|
||||
let spanStartY = samples.startY;
|
||||
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
|
||||
spanStartTs = samples.sampleTs;
|
||||
spanStartY = samples.sampleY;
|
||||
} else if (samples.previousSampleTs > 0) {
|
||||
spanStartTs = samples.previousSampleTs;
|
||||
spanStartY = samples.previousSampleY;
|
||||
}
|
||||
|
||||
const spanDurationMs = releaseTs - spanStartTs;
|
||||
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
|
||||
|
||||
return {
|
||||
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
TextInput,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
} from "./model";
|
||||
|
||||
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
|
||||
|
||||
/**
|
||||
* Owns the chat history's flick-to-dismiss behavior. The native stream only
|
||||
* forwards the FlatList scroll lifecycle; removing this hook and those three
|
||||
* calls removes the feature completely.
|
||||
*/
|
||||
export function useScrollKeyboardDismiss() {
|
||||
const { shift } = useKeyboardShift();
|
||||
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
|
||||
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = beginDrag(event);
|
||||
});
|
||||
|
||||
const onScroll = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = recordScroll(gestureRef.current, event);
|
||||
});
|
||||
|
||||
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
|
||||
const release = releaseDrag(gestureRef.current, event);
|
||||
gestureRef.current = release.gesture;
|
||||
|
||||
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
|
||||
// second calculation on JS, this prevents a hardware keyboard's focused
|
||||
// composer from being blurred when no software keyboard occupies space.
|
||||
if (!release.shouldDismiss || shift.value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep blur and dismiss paired: this exact sequence was validated on a
|
||||
// physical Android device to clear both input focus and the IME inset.
|
||||
const focusedInput = TextInput.State.currentlyFocusedInput();
|
||||
if (focusedInput) {
|
||||
TextInput.State.blurTextInput(focusedInput);
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
});
|
||||
|
||||
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
scrollKeyboardDismiss.onScroll(event);
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent(() => {
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// gesture position now because layout may move the viewport in the meantime.
|
||||
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const isNearBottom = isScrollEventNearBottom(event);
|
||||
scrollKeyboardDismiss.onScrollEndDrag(event);
|
||||
|
||||
clearPendingUserScrollEnd();
|
||||
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||
userScrollEndFrameIdRef.current = null;
|
||||
|
||||
@@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes HTML table cells and inline formatting into markdown", () => {
|
||||
expect(
|
||||
normalizeHtmlishMarkdown(
|
||||
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
|
||||
),
|
||||
).toBe(
|
||||
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
|
||||
@@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
del: ["~~", "~~"],
|
||||
em: ["*", "*"],
|
||||
i: ["*", "*"],
|
||||
s: ["~~", "~~"],
|
||||
strike: ["~~", "~~"],
|
||||
strong: ["**", "**"],
|
||||
};
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
@@ -313,36 +322,83 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
output += renderHtmlTag(token, children);
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
if (token.name === "a") {
|
||||
return renderLinkToken(token, children);
|
||||
}
|
||||
if (token.name === "sub" || isHeadingTagName(token.name)) {
|
||||
return renderInlineTokens(children);
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
return `\`${renderInlineTokens(children)}\``;
|
||||
}
|
||||
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
|
||||
if (wrapper) {
|
||||
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
|
||||
}
|
||||
if (token.name === "table") {
|
||||
return renderTableTokens(children);
|
||||
}
|
||||
if (token.name === "p" || token.name === "div") {
|
||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||
}
|
||||
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
|
||||
}
|
||||
|
||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||
const rows: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
if (!isOpenTag(tokens[index], "tr")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, "tr");
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
|
||||
if (cells.length === 1) {
|
||||
rows.push(`- ${cells[0]}`);
|
||||
} else if (cells.length > 1) {
|
||||
const label =
|
||||
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
|
||||
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function renderTableCells(tokens: HtmlToken[]): string[] {
|
||||
const cells: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
getIsElectron,
|
||||
} from "@/constants/platform";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
|
||||
@@ -609,6 +610,10 @@ function ProjectKebabMenu({
|
||||
{t("sidebar.project.actions.openNewWindow")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={projectPath}
|
||||
testID={`sidebar-project-menu-open-folder-${projectKey}`}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
@@ -660,6 +665,7 @@ function WorkspaceRowRightGroup({
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
@@ -698,6 +704,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspacePath}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -58,6 +59,7 @@ interface SidebarWorkspaceMenuProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
openInFileManagerPath?: string | null;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
@@ -73,6 +75,7 @@ export function SidebarWorkspaceMenu({
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
openInFileManagerPath,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
@@ -137,6 +140,10 @@ export function SidebarWorkspaceMenu({
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<OpenInFileManagerMenuItem
|
||||
path={openInFileManagerPath}
|
||||
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
|
||||
@@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => {
|
||||
).toBe("https://codeberg.org/acme/repo/src/branch/main");
|
||||
});
|
||||
|
||||
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||
branch: "master",
|
||||
}),
|
||||
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master");
|
||||
});
|
||||
|
||||
it("omits the port for a self-hosted remote on the default port", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com/team/repo.git",
|
||||
branch: "master",
|
||||
}),
|
||||
).toBe("https://home-git.example.com/team/repo/src/branch/master");
|
||||
});
|
||||
|
||||
it("returns null when the current branch is unavailable", () => {
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", {
|
||||
@@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => {
|
||||
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("forgejo", {
|
||||
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||
branch: "master",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20");
|
||||
});
|
||||
|
||||
it("canonicalizes the github.com SSH-alias host to the web host", () => {
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput {
|
||||
|
||||
interface ForgeWebLocation {
|
||||
host: string;
|
||||
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
||||
port?: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
@@ -58,18 +60,28 @@ function resolveForgeWebLocation(
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
|
||||
const webHost =
|
||||
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
|
||||
? normalizeHost(cloudHosts[0])
|
||||
: location.host;
|
||||
return { host: webHost, repo: location.path };
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
const port =
|
||||
!isCloudHost && (location.transport === "http" || location.transport === "https")
|
||||
? location.port
|
||||
: undefined;
|
||||
return { host: webHost, port, repo: location.path };
|
||||
}
|
||||
|
||||
function encodeBranch(branch: string): string {
|
||||
return branch.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/** Host, plus `:port` when the remote pins a non-default port. */
|
||||
function forgeAuthority(location: ForgeWebLocation): string {
|
||||
return location.port ? `${location.host}:${location.port}` : location.host;
|
||||
}
|
||||
|
||||
function normalizeBlobPath(path: string | null | undefined): string | null {
|
||||
const segments: string[] = [];
|
||||
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
@@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl(
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
}
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
@@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
|
||||
return null;
|
||||
}
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
if (input.lineStart && input.lineStart > 0) {
|
||||
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
||||
}
|
||||
|
||||
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal file
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
|
||||
export interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
/** Read the app-wide keyboard inset without loading its native provider implementation. */
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createElement, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -23,16 +16,10 @@ import {
|
||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
resolveKeyboardShift,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
type KeyboardShiftMode = "translate" | "padding";
|
||||
|
||||
interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
|
||||
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
return createElement(KeyboardShiftContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
|
||||
shift: SharedValue<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
|
||||
@@ -908,6 +908,8 @@ export const ar: TranslationResources = {
|
||||
openSettings: "افتح إعدادات المشروع",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "إزالة المشروع",
|
||||
removing: "جارٍ الإزالة...",
|
||||
},
|
||||
@@ -1831,6 +1833,7 @@ export const ar: TranslationResources = {
|
||||
sendMessage: "أرسل رسالة",
|
||||
queueMessage: "رسالة قائمة الانتظار",
|
||||
muteUnmuteVoiceMode: "كتم وضع الصوت /unmute",
|
||||
switchProject: "تبديل المشروع",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.",
|
||||
|
||||
@@ -918,6 +918,8 @@ export const en = {
|
||||
openSettings: "Open project settings",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Remove project",
|
||||
removing: "Removing...",
|
||||
},
|
||||
@@ -1841,6 +1843,7 @@ export const en = {
|
||||
sendMessage: "Send message",
|
||||
queueMessage: "Queue message",
|
||||
muteUnmuteVoiceMode: "Mute/unmute voice mode",
|
||||
switchProject: "Switch project",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Available when focus is not in a text field or terminal.",
|
||||
|
||||
@@ -939,6 +939,8 @@ export const es: TranslationResources = {
|
||||
openSettings: "Abrir la configuración del proyecto",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Eliminar proyecto",
|
||||
removing: "Eliminando...",
|
||||
},
|
||||
@@ -1879,6 +1881,7 @@ export const es: TranslationResources = {
|
||||
sendMessage: "enviar mensaje",
|
||||
queueMessage: "mensaje de cola",
|
||||
muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute",
|
||||
switchProject: "Cambiar proyecto",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.",
|
||||
|
||||
@@ -938,6 +938,8 @@ export const fr: TranslationResources = {
|
||||
openSettings: "Ouvrir les paramètres du projet",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Supprimer le projet",
|
||||
removing: "Suppression...",
|
||||
},
|
||||
@@ -1881,6 +1883,7 @@ export const fr: TranslationResources = {
|
||||
sendMessage: "Envoyer un message",
|
||||
queueMessage: "Message de file d'attente",
|
||||
muteUnmuteVoiceMode: "Mode vocal/unmutemuet",
|
||||
switchProject: "Changer de projet",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -919,6 +919,8 @@ export const ja: TranslationResources = {
|
||||
openSettings: "プロジェクト設定を開く",
|
||||
openNewWindow: "新しいウィンドウで開く",
|
||||
openNewWindowFailed: "新しいウィンドウを開けませんでした",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "プロジェクトを削除",
|
||||
removing: "削除中...",
|
||||
},
|
||||
@@ -1847,6 +1849,7 @@ export const ja: TranslationResources = {
|
||||
sendMessage: "メッセージを送信",
|
||||
queueMessage: "メッセージをキューに追加",
|
||||
muteUnmuteVoiceMode: "音声モードのミュートを切り替え",
|
||||
switchProject: "プロジェクトを切り替え",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -930,6 +930,8 @@ export const ptBR: TranslationResources = {
|
||||
openSettings: "Abrir configurações do projeto",
|
||||
openNewWindow: "Abrir em nova janela",
|
||||
openNewWindowFailed: "Não foi possível abrir uma nova janela",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Remover projeto",
|
||||
removing: "Removendo...",
|
||||
},
|
||||
@@ -1862,6 +1864,7 @@ export const ptBR: TranslationResources = {
|
||||
sendMessage: "Enviar mensagem",
|
||||
queueMessage: "Enfileirar mensagem",
|
||||
muteUnmuteVoiceMode: "Silenciar/ativar modo de voz",
|
||||
switchProject: "Trocar projeto",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts:
|
||||
|
||||
@@ -930,6 +930,8 @@ export const ru: TranslationResources = {
|
||||
openSettings: "Открыть настройки проекта",
|
||||
openNewWindow: "Open in new window",
|
||||
openNewWindowFailed: "Couldn't open a new window",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "Удалить проект",
|
||||
removing: "Удаление...",
|
||||
},
|
||||
@@ -1869,6 +1871,7 @@ export const ru: TranslationResources = {
|
||||
sendMessage: "Отправить сообщение",
|
||||
queueMessage: "Сообщение в очереди",
|
||||
muteUnmuteVoiceMode: "Отключить голосовой режим /unmute",
|
||||
switchProject: "Сменить проект",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.",
|
||||
|
||||
@@ -899,6 +899,8 @@ export const zhCN: TranslationResources = {
|
||||
openSettings: "打开 project 设置",
|
||||
openNewWindow: "在新窗口中打开",
|
||||
openNewWindowFailed: "无法打开新窗口",
|
||||
openFolder: "Open in file manager",
|
||||
openFolderFailed: "Couldn't open folder",
|
||||
remove: "移除 project",
|
||||
removing: "正在移除...",
|
||||
},
|
||||
@@ -1810,6 +1812,7 @@ export const zhCN: TranslationResources = {
|
||||
sendMessage: "发送消息",
|
||||
queueMessage: "消息排队",
|
||||
muteUnmuteVoiceMode: "静音/取消静音语音模式",
|
||||
switchProject: "切换项目",
|
||||
},
|
||||
helpNotes: {
|
||||
showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。",
|
||||
|
||||
@@ -44,6 +44,7 @@ export type KeyboardActionId =
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin"
|
||||
|
||||
@@ -29,6 +29,7 @@ export type KeyboardActionId =
|
||||
| "workspace.terminal.new"
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin";
|
||||
@@ -62,6 +63,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.archive"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pin"; scope: KeyboardActionScope };
|
||||
|
||||
@@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => {
|
||||
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
|
||||
action: "workspace.new",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+P to switch project on mac",
|
||||
event: { key: "p", code: "KeyP", metaKey: true },
|
||||
context: { isMac: true, commandCenterOpen: false },
|
||||
action: "workspace.project.pick",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+P to switch project on non-mac",
|
||||
event: { key: "p", code: "KeyP", ctrlKey: true },
|
||||
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
|
||||
action: "workspace.project.pick",
|
||||
},
|
||||
{
|
||||
name: "matches question-mark shortcut to toggle the shortcuts dialog",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
@@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not switch project with Ctrl+P on non-mac while terminal is focused",
|
||||
event: { key: "p", code: "KeyP", ctrlKey: true },
|
||||
context: { isMac: false, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not switch project with Cmd+P while the command center is open",
|
||||
event: { key: "p", code: "KeyP", metaKey: true },
|
||||
context: { isMac: true, commandCenterOpen: true },
|
||||
},
|
||||
{
|
||||
name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)",
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
|
||||
@@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"switch-project": "settings.shortcuts.help.switchProject",
|
||||
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||
@@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Switch project (New Workspace screen) ---
|
||||
{
|
||||
id: "workspace-project-pick-cmd-p-mac",
|
||||
action: "workspace.project.pick",
|
||||
combo: "Cmd+P",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-project-pick-ctrl-p-non-mac",
|
||||
action: "workspace.project.pick",
|
||||
combo: "Ctrl+P",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive workspace ---
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
|
||||
["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }],
|
||||
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
|
||||
["workspace.pin", { id: "workspace.pin", scope: "sidebar" }],
|
||||
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||
|
||||
@@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||
"workspace.new": { id: "workspace.new", scope: "sidebar" },
|
||||
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
|
||||
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
|
||||
"workspace.pin": { id: "workspace.pin", scope: "sidebar" },
|
||||
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
type PendingWorkspaceDraftSetup,
|
||||
} from "@/stores/workspace-draft-submission-store";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
@@ -181,6 +183,8 @@ interface PickerOptionData {
|
||||
const BRANCH_OPTION_PREFIX = "branch:";
|
||||
const PR_OPTION_PREFIX = "github-pr:";
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
// Stable reference so the keyboard-action handler doesn't re-register each render.
|
||||
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
|
||||
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
|
||||
// this so toggling Isolation to Local hides the row without shifting the form.
|
||||
const BADGE_HEIGHT = 28;
|
||||
@@ -1788,6 +1792,22 @@ export function NewWorkspaceScreen({
|
||||
setProjectPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
// Cmd/Ctrl+P opens the project picker with its search focused so the user can
|
||||
// switch projects from the keyboard. Registered only while this screen is
|
||||
// mounted, so the shortcut doesn't swallow the browser's native print
|
||||
// elsewhere; gated on having projects to pick.
|
||||
const handleProjectPick = useCallback(() => {
|
||||
openProjectPicker();
|
||||
return true;
|
||||
}, [openProjectPicker]);
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "new-workspace-project-pick",
|
||||
actions: PROJECT_PICK_ACTIONS,
|
||||
enabled: projectPickerOptions.length > 0,
|
||||
priority: 0,
|
||||
handle: handleProjectPick,
|
||||
});
|
||||
|
||||
const openIsolationPicker = useCallback(() => {
|
||||
setIsolationPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
interface OpenInFileManagerMenuItemProps {
|
||||
path?: string | null;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
const ThemedFolderOpen = withUnistyles(FolderOpen);
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const leadingIcon = <ThemedFolderOpen size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
export function OpenInFileManagerMenuItem({ path, testID }: OpenInFileManagerMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isElectron = getIsElectron();
|
||||
const workspacePath = path?.trim() ?? "";
|
||||
const { targets } = useDesktopOpenTargets({
|
||||
isLocalExecution: isElectron && workspacePath.length > 0,
|
||||
});
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
const openInFileManager = useCallback(() => {
|
||||
if (!fileManagerTarget || workspacePath.length === 0) return;
|
||||
void openDesktopTarget({
|
||||
editorId: fileManagerTarget.id,
|
||||
workspacePath,
|
||||
}).catch((error) => {
|
||||
console.warn("[open-in-file-manager] open failed", error);
|
||||
toast.error(t("sidebar.project.actions.openFolderFailed"));
|
||||
});
|
||||
}, [fileManagerTarget, t, toast, workspacePath]);
|
||||
|
||||
if (!isElectron || !fileManagerTarget || workspacePath.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem testID={testID} leading={leadingIcon} onSelect={openInFileManager}>
|
||||
{t("sidebar.project.actions.openFolder")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,19 @@ describe("canonical CLI surface", () => {
|
||||
expect(run?.helpInformation()).not.toContain("--detach");
|
||||
});
|
||||
|
||||
it("offers thinking configuration when running, updating, and scheduling agents", () => {
|
||||
const cli = createCli();
|
||||
const run = cli.commands.find((command) => command.name() === "run");
|
||||
const agent = cli.commands.find((command) => command.name() === "agent");
|
||||
const update = agent?.commands.find((command) => command.name() === "update");
|
||||
const schedule = cli.commands.find((command) => command.name() === "schedule");
|
||||
const scheduleCreate = schedule?.commands.find((command) => command.name() === "create");
|
||||
|
||||
expect(run?.helpInformation()).toContain("--thinking <id>");
|
||||
expect(update?.helpInformation()).toContain("--thinking <id>");
|
||||
expect(scheduleCreate?.helpInformation()).toContain("--thinking <id>");
|
||||
});
|
||||
|
||||
it("offers opening an existing agent in the desktop app", () => {
|
||||
const agent = createCli().commands.find((command) => command.name() === "agent");
|
||||
const open = agent?.commands.find((command) => command.name() === "open");
|
||||
|
||||
@@ -92,9 +92,10 @@ export function createAgentCommand(): Command {
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("update")
|
||||
.description("Update an agent's metadata")
|
||||
.description("Update an agent's settings or metadata")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("--name <name>", "Update the agent's display name")
|
||||
.option("--thinking <id>", "Update the agent's thinking option ID")
|
||||
.option(
|
||||
"--label <label>",
|
||||
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
|
||||
|
||||
108
packages/cli/src/commands/agent/update.test.ts
Normal file
108
packages/cli/src/commands/agent/update.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
applyAgentChanges,
|
||||
toAgentUpdateResult,
|
||||
type AgentMetadataChanges,
|
||||
type AgentUpdateClient,
|
||||
} from "./update.js";
|
||||
|
||||
class RecordingAgentUpdateClient implements AgentUpdateClient {
|
||||
readonly metadataUpdates: Array<{
|
||||
agentId: string;
|
||||
updates: AgentMetadataChanges;
|
||||
}> = [];
|
||||
readonly thinkingUpdates: Array<{ agentId: string; thinkingOptionId: string }> = [];
|
||||
thinkingNotice: AgentProviderNotice | null = null;
|
||||
|
||||
constructor(private readonly supportsThinkingUpdate = true) {}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return { features: { agentThinkingUpdate: this.supportsThinkingUpdate } };
|
||||
}
|
||||
|
||||
async updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void> {
|
||||
this.metadataUpdates.push({ agentId, updates });
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
this.thinkingUpdates.push({ agentId, thinkingOptionId });
|
||||
return this.thinkingNotice;
|
||||
}
|
||||
}
|
||||
|
||||
describe("applyAgentChanges", () => {
|
||||
it("updates an agent's thinking without issuing an empty metadata update", async () => {
|
||||
const client = new RecordingAgentUpdateClient();
|
||||
|
||||
const result = await applyAgentChanges(client, "agent-1", {
|
||||
type: "thinking",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(client.metadataUpdates).toEqual([]);
|
||||
expect(client.thinkingUpdates).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
||||
expect(result).toEqual({
|
||||
notice: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a daemon that advertises thinking updates", async () => {
|
||||
const client = new RecordingAgentUpdateClient(false);
|
||||
|
||||
await expect(
|
||||
applyAgentChanges(client, "agent-1", { type: "thinking", thinkingOptionId: "high" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to use agent thinking updates.",
|
||||
});
|
||||
expect(client.metadataUpdates).toEqual([]);
|
||||
expect(client.thinkingUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the provider notice from a thinking update", async () => {
|
||||
const client = new RecordingAgentUpdateClient();
|
||||
client.thinkingNotice = {
|
||||
type: "warning",
|
||||
message: "Thinking changes apply to the next turn.",
|
||||
};
|
||||
|
||||
const notice = await applyAgentChanges(client, "agent-1", {
|
||||
type: "thinking",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
notice: {
|
||||
type: "warning",
|
||||
message: "Thinking changes apply to the next turn.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAgentUpdateResult", () => {
|
||||
it("reports the current thinking option after a metadata update", () => {
|
||||
const result = toAgentUpdateResult(
|
||||
{
|
||||
id: "agent-1",
|
||||
title: "Renamed agent",
|
||||
labels: { team: "platform" },
|
||||
effectiveThinkingOptionId: "high",
|
||||
},
|
||||
{ notice: null },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
agentId: "agent-1",
|
||||
name: "Renamed agent",
|
||||
labels: "team=platform",
|
||||
thinkingOptionId: "high",
|
||||
noticeType: null,
|
||||
notice: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -12,6 +14,9 @@ export interface AgentUpdateResult {
|
||||
agentId: string;
|
||||
name: string | null;
|
||||
labels: string;
|
||||
thinkingOptionId: string | null;
|
||||
noticeType: AgentProviderNotice["type"] | null;
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
/** Schema for update command output */
|
||||
@@ -21,17 +26,80 @@ export const updateSchema: OutputSchema<AgentUpdateResult> = {
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "NAME", field: "name" },
|
||||
{ header: "LABELS", field: "labels" },
|
||||
{ header: "THINKING", field: "thinkingOptionId" },
|
||||
{ header: "NOTICE", field: "notice" },
|
||||
],
|
||||
};
|
||||
|
||||
export interface AgentUpdateOptions extends CommandOptions {
|
||||
name?: string;
|
||||
label?: string[];
|
||||
thinking?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
|
||||
|
||||
export interface AgentMetadataChanges {
|
||||
name?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AgentUpdateServerInfo {
|
||||
features?: { agentThinkingUpdate?: boolean };
|
||||
}
|
||||
|
||||
export interface AgentUpdateClient {
|
||||
getLastServerInfoMessage(): AgentUpdateServerInfo | null;
|
||||
updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void>;
|
||||
setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string,
|
||||
): Promise<AgentProviderNotice | null>;
|
||||
}
|
||||
|
||||
export type AgentChanges =
|
||||
| { type: "metadata"; updates: AgentMetadataChanges }
|
||||
| { type: "thinking"; thinkingOptionId: string };
|
||||
|
||||
export interface AppliedAgentChanges {
|
||||
notice: AgentProviderNotice | null;
|
||||
}
|
||||
|
||||
export function toAgentUpdateResult(
|
||||
agent: Pick<AgentSnapshotPayload, "id" | "title" | "labels" | "effectiveThinkingOptionId">,
|
||||
appliedChanges: AppliedAgentChanges,
|
||||
): AgentUpdateResult {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name: agent.title,
|
||||
labels: formatLabels(agent.labels),
|
||||
thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
|
||||
noticeType: appliedChanges.notice?.type ?? null,
|
||||
notice: appliedChanges.notice?.message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyAgentChanges(
|
||||
client: AgentUpdateClient,
|
||||
agentId: string,
|
||||
changes: AgentChanges,
|
||||
): Promise<AppliedAgentChanges> {
|
||||
if (changes.type === "thinking") {
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
|
||||
throw {
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to use agent thinking updates.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
|
||||
return { notice };
|
||||
}
|
||||
await client.updateAgent(agentId, changes.updates);
|
||||
return { notice: null };
|
||||
}
|
||||
|
||||
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
if (!labels) {
|
||||
@@ -81,6 +149,55 @@ function formatLabels(labels: Record<string, string>): string {
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join(",");
|
||||
}
|
||||
|
||||
function parseAgentChanges(options: AgentUpdateOptions): AgentChanges {
|
||||
const name = options.name?.trim();
|
||||
if (options.name !== undefined && !name) {
|
||||
throw {
|
||||
code: "INVALID_NAME",
|
||||
message: "Name cannot be empty",
|
||||
details: "Use --name <name> with a non-empty value",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label);
|
||||
const thinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
details:
|
||||
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
|
||||
if (hasMetadataUpdates && thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--thinking cannot be combined with --name or --label",
|
||||
details: "Run separate agent update commands for runtime settings and metadata.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (!hasMetadataUpdates && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "NO_CHANGES_PROVIDED",
|
||||
message: "Nothing to update",
|
||||
details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (thinkingOptionId) {
|
||||
return { type: "thinking", thinkingOptionId };
|
||||
}
|
||||
return {
|
||||
type: "metadata",
|
||||
updates: {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runUpdateCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentUpdateOptions,
|
||||
@@ -98,25 +215,7 @@ export async function runUpdateCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const name = options.name?.trim();
|
||||
if (options.name !== undefined && !name) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_NAME",
|
||||
message: "Name cannot be empty",
|
||||
details: "Use --name <name> with a non-empty value",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label);
|
||||
if (!name && Object.keys(labels).length === 0) {
|
||||
const error: CommandError = {
|
||||
code: "NO_CHANGES_PROVIDED",
|
||||
message: "Nothing to update",
|
||||
details: "Provide at least one of: --name <name>, --label <key=value>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const changes = parseAgentChanges(options);
|
||||
|
||||
let client;
|
||||
try {
|
||||
@@ -143,10 +242,7 @@ export async function runUpdateCommand(
|
||||
}
|
||||
const agentId = fetchResult.agent.id;
|
||||
|
||||
await client.updateAgent(agentId, {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
});
|
||||
const appliedChanges = await applyAgentChanges(client, agentId, changes);
|
||||
|
||||
const updatedResult = await client.fetchAgent({ agentId });
|
||||
if (!updatedResult) {
|
||||
@@ -157,11 +253,7 @@ export async function runUpdateCommand(
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId,
|
||||
name: updatedResult.agent.title,
|
||||
labels: formatLabels(updatedResult.agent.labels),
|
||||
},
|
||||
data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
|
||||
schema: updateSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
target?: string;
|
||||
provider?: string;
|
||||
mode?: string;
|
||||
thinking?: string;
|
||||
cwd?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
@@ -40,6 +41,7 @@ export async function runCreateCommand(
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
mode: options.mode,
|
||||
thinking: options.thinking,
|
||||
cwd: options.cwd,
|
||||
host: options.host,
|
||||
maxRuns: options.maxRuns,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createScheduleCommand(): Command {
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID for new-agent runs")
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
|
||||
@@ -116,6 +116,25 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleCreateInput thinking", () => {
|
||||
test("sets the thinking option for each scheduled new-agent run", () => {
|
||||
const input = parseScheduleCreateInput({
|
||||
...baseOptions,
|
||||
cwd: "/project",
|
||||
thinking: " high ",
|
||||
});
|
||||
|
||||
expect(input.target).toEqual({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/project",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleUpdateInput", () => {
|
||||
test("rejects calls with no fields to update", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow(
|
||||
|
||||
@@ -114,7 +114,7 @@ function resolveScheduleTarget(args: {
|
||||
if (hasExplicitNewAgentOption) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider/--mode can only be used with a new-agent target",
|
||||
message: "--provider/--mode/--thinking 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;
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export function parseScheduleCreateInput(options: {
|
||||
target?: string;
|
||||
provider?: string;
|
||||
mode?: string;
|
||||
thinking?: string;
|
||||
cwd?: string;
|
||||
host?: string;
|
||||
maxRuns?: string;
|
||||
@@ -179,7 +180,15 @@ export function parseScheduleCreateInput(options: {
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const modeId = options.mode?.trim();
|
||||
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
||||
const thinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const hasExplicitNewAgentOption =
|
||||
options.provider !== undefined || options.mode !== undefined || options.thinking !== undefined;
|
||||
const createNewAgentTarget = (): ScheduleTarget => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: options.provider,
|
||||
@@ -191,6 +200,7 @@ export function parseScheduleCreateInput(options: {
|
||||
cwd: cwdInput ?? process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -108,16 +108,6 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
] as const;
|
||||
|
||||
const EXPECTED_CLAUDE_CONTEXT_MODELS = [
|
||||
{
|
||||
id: "claude-opus-5[1m]",
|
||||
model: "Opus 5 1M",
|
||||
descriptionFragment: "1M context window",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
model: "Opus 5",
|
||||
descriptionFragment: "200K context window",
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5[1m]",
|
||||
model: "Fable 5 1M",
|
||||
|
||||
@@ -33,6 +33,7 @@ try {
|
||||
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
|
||||
assert(result.stdout.includes("--name"), "help should mention --name flag");
|
||||
assert(result.stdout.includes("--label"), "help should mention --label flag");
|
||||
assert(result.stdout.includes("--thinking"), "help should mention --thinking flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention required id argument");
|
||||
console.log("✓ agent update --help shows options\n");
|
||||
@@ -103,6 +104,30 @@ try {
|
||||
assert(result.stdout.includes("update"), "help should mention update subcommand");
|
||||
console.log("✓ agent --help shows update subcommand\n");
|
||||
}
|
||||
|
||||
// Test 7: agent update accepts thinking as an update field
|
||||
{
|
||||
console.log("Test 7: agent update accepts thinking as an update field");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("Nothing to update"), "should treat --thinking as an update field");
|
||||
console.log("✓ agent update accepts thinking as an update field\n");
|
||||
}
|
||||
|
||||
// Test 8: thinking cannot be combined with metadata updates
|
||||
{
|
||||
console.log("Test 8: thinking cannot be combined with metadata updates");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should reject a combined update");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--thinking cannot be combined with --name or --label"),
|
||||
"should explain that thinking and metadata updates are separate operations",
|
||||
);
|
||||
console.log("✓ thinking cannot be combined with metadata updates\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -100,6 +100,8 @@ try {
|
||||
"10m",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
@@ -113,6 +115,7 @@ try {
|
||||
const inspectedJson = JSON.parse(inspected.stdout);
|
||||
assert.strictEqual(inspectedJson.target.config.provider, "codex");
|
||||
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
|
||||
assert.strictEqual(inspectedJson.target.config.thinkingOptionId, "high");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js";
|
||||
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
app,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { configureLinuxSandbox } from "./system/linux-sandbox.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
registerNotificationHandlers,
|
||||
@@ -309,12 +310,13 @@ if (forcedUserDataDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID
|
||||
// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the
|
||||
// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code.
|
||||
if (process.platform === "linux" && process.env.APPIMAGE) {
|
||||
app.commandLine.appendSwitch("no-sandbox");
|
||||
}
|
||||
configureLinuxSandbox({
|
||||
platform: process.platform,
|
||||
resourcesPath: process.resourcesPath,
|
||||
statSandbox: (sandboxPath) => statSync(sandboxPath),
|
||||
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
|
||||
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
|
||||
});
|
||||
|
||||
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
||||
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
||||
|
||||
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal file
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configureLinuxSandbox } from "./linux-sandbox";
|
||||
|
||||
interface SandboxMetadata {
|
||||
mode: number;
|
||||
uid: number;
|
||||
}
|
||||
|
||||
function configureWithSandbox(
|
||||
sandbox: SandboxMetadata | Error,
|
||||
platform: NodeJS.Platform = "linux",
|
||||
) {
|
||||
const disabledSwitches: string[] = [];
|
||||
const inspectionErrors: unknown[] = [];
|
||||
|
||||
configureLinuxSandbox({
|
||||
platform,
|
||||
resourcesPath: "/opt/Paseo/resources",
|
||||
statSandbox: () => {
|
||||
if (sandbox instanceof Error) {
|
||||
throw sandbox;
|
||||
}
|
||||
return sandbox;
|
||||
},
|
||||
disableSandbox: () => disabledSwitches.push("no-sandbox"),
|
||||
reportInspectionError: (error) => inspectionErrors.push(error),
|
||||
});
|
||||
|
||||
return { disabledSwitches, inspectionErrors };
|
||||
}
|
||||
|
||||
function createFileSystemError(code: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(code);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("configureLinuxSandbox", () => {
|
||||
it("disables the sandbox when an AppImage mount strips SUID", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sandbox for a root-owned 4755 helper", () => {
|
||||
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the sandbox when a SUID helper is not root-owned", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the sandbox when the helper is missing", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sandbox and reports unexpected inspection failures", () => {
|
||||
const permissionError = createFileSystemError("EACCES");
|
||||
|
||||
expect(configureWithSandbox(permissionError)).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [permissionError],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inspect or configure the sandbox outside Linux", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/desktop/src/system/linux-sandbox.ts
Normal file
45
packages/desktop/src/system/linux-sandbox.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import path from "node:path";
|
||||
|
||||
const REQUIRED_SANDBOX_MODE = 0o4755;
|
||||
const PERMISSION_BITS = 0o7777;
|
||||
|
||||
interface SandboxMetadata {
|
||||
mode: number;
|
||||
uid: number;
|
||||
}
|
||||
|
||||
interface LinuxSandboxConfiguration {
|
||||
platform: NodeJS.Platform;
|
||||
resourcesPath: string;
|
||||
statSandbox: (sandboxPath: string) => SandboxMetadata;
|
||||
disableSandbox: () => void;
|
||||
reportInspectionError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function isMissingSandbox(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
|
||||
if (input.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
||||
const sandbox = input.statSandbox(sandboxPath);
|
||||
const hasUsableSandbox =
|
||||
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
|
||||
|
||||
if (!hasUsableSandbox) {
|
||||
input.disableSandbox();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingSandbox(error)) {
|
||||
input.disableSandbox();
|
||||
return;
|
||||
}
|
||||
|
||||
input.reportInspectionError(error);
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,25 @@ describe("isCompleteGitRemote", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitRemoteLocation port", () => {
|
||||
it("preserves an explicit non-default port from an https remote", () => {
|
||||
expect(parseGitRemoteLocation("https://home-git.example.com:60443/team/repo.git")?.port).toBe(
|
||||
"60443",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a port from a plain http remote", () => {
|
||||
expect(parseGitRemoteLocation("http://internal.example.com:3000/team/repo.git")?.port).toBe(
|
||||
"3000",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the port for a default-port remote", () => {
|
||||
expect(parseGitRemoteLocation("https://github.com/acme/repo.git")?.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has no port for an scp-form remote", () => {
|
||||
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
export interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
/**
|
||||
* Explicit non-default port from the remote (e.g. a self-hosted forge on
|
||||
* `:60443`), or undefined for a default-port or scp-form remote. Kept separate
|
||||
* from `host` so host-identity matching (forge detection, cloud-host checks)
|
||||
* stays port-agnostic; only consumers that reconstruct a URL (web links) use it.
|
||||
*/
|
||||
port?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@@ -71,7 +78,7 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, path: normalizedPath };
|
||||
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
|
||||
@@ -168,4 +168,14 @@ describe("project command-center protocol", () => {
|
||||
expect(parsed.features?.projectGithubClone).toBeUndefined();
|
||||
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses the agent thinking update capability", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "server-new",
|
||||
features: { agentThinkingUpdate: true },
|
||||
});
|
||||
|
||||
expect(parsed.features?.agentThinkingUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2792,6 +2792,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: z.boolean().optional(),
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
agentThinkingUpdate: z.boolean().optional(),
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -26,7 +26,7 @@ export type AgentLoaderManager = Pick<
|
||||
| "hydrateTimelineFromProvider"
|
||||
| "resumeAgentFromPersistence"
|
||||
> &
|
||||
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>;
|
||||
Partial<Pick<AgentManager, "waitForAgentClose">>;
|
||||
|
||||
export interface EnsureAgentLoadedDeps {
|
||||
agentManager: AgentLoaderManager;
|
||||
@@ -71,8 +71,7 @@ export async function ensureAgentLoaded(
|
||||
return inflight.promise;
|
||||
}
|
||||
|
||||
const existing =
|
||||
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
|
||||
const existing = deps.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -7257,108 +7257,57 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let activeSession: TestAgentSession | null = null;
|
||||
const client = new (class extends NativeArchiveRecordingClient {
|
||||
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
|
||||
let closeCount = 0;
|
||||
let resumeCount = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
activeSession = new TestAgentSession(config);
|
||||
return activeSession;
|
||||
const recordClose = () => {
|
||||
closeCount += 1;
|
||||
};
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
recordClose();
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
resumeCount += 1;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000210",
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
|
||||
try {
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: "workspace-idle-collection",
|
||||
});
|
||||
await manager.appendTimelineItem(created.id, {
|
||||
type: "user_message",
|
||||
text: "Keep this timeline",
|
||||
});
|
||||
activeSession?.pushEvent({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "retained-provider-child",
|
||||
title: "Retained provider child",
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
await manager.flush();
|
||||
const timelineBeforeCollection = manager.getTimeline(created.id);
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
expect(collection).toEqual({
|
||||
collected: [
|
||||
{
|
||||
agentId: created.id,
|
||||
provider: "codex",
|
||||
sessionId: created.persistence?.sessionId,
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
});
|
||||
expect(manager.getAgent(created.id)).toBeNull();
|
||||
expect(client.archivedHandles).toEqual([]);
|
||||
const stored = await storage.get(created.id);
|
||||
expect(stored).toMatchObject({
|
||||
id: created.id,
|
||||
lastStatus: "closed",
|
||||
workspaceId: "workspace-idle-collection",
|
||||
});
|
||||
expect(stored?.archivedAt).toBeFalsy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const resumed = await ensureAgentLoaded(created.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
||||
expect(closeCount).toBe(0);
|
||||
|
||||
expect(resumed.id).toBe(created.id);
|
||||
expect(resumed.persistence).toEqual(created.persistence);
|
||||
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection);
|
||||
expect(manager.listProviderSubagents(created.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "retained-provider-child",
|
||||
title: "Retained provider child",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
const idleBeforeOpen = resumed.updatedAt;
|
||||
await ensureAgentLoaded(created.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
await expect(
|
||||
manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }),
|
||||
).resolves.toMatchObject({ collected: [] });
|
||||
await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({
|
||||
finalText: "",
|
||||
canceled: false,
|
||||
});
|
||||
expect(manager.getAgent(created.id)?.id).toBe(created.id);
|
||||
await manager.runAgent(agent.id, "Continue on the resident runtime");
|
||||
|
||||
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
|
||||
expect(resumeCount).toBe(0);
|
||||
} finally {
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("archiving an idle-collected parent still cascades to its managed children", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-"));
|
||||
test("archiving a closed parent still cascades to its managed children", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
@@ -7368,7 +7317,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
|
||||
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Collected parent" },
|
||||
{ provider: "codex", cwd: workdir, title: "Closed parent" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
@@ -7381,10 +7330,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
|
||||
},
|
||||
);
|
||||
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set([child.id]),
|
||||
});
|
||||
await manager.closeAgent(parent.id);
|
||||
await manager.archiveSnapshot(parent.id, new Date().toISOString());
|
||||
|
||||
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
|
||||
@@ -7410,10 +7356,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () =
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.archiveSnapshot(agent.id, new Date().toISOString());
|
||||
|
||||
await expect(
|
||||
@@ -7452,10 +7395,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await manager.closeAgent(agent.id);
|
||||
|
||||
const load = ensureUnarchivedAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
@@ -7498,10 +7438,7 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await manager.closeAgent(agent.id);
|
||||
|
||||
const sharedLoad = ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
@@ -7557,10 +7494,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.deleteAgentState(agent.id);
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false });
|
||||
@@ -7598,166 +7532,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
|
||||
const client = new SessionRecordingAgentClient();
|
||||
const ids = [
|
||||
"00000000-0000-4000-8000-000000000211",
|
||||
"00000000-0000-4000-8000-000000000212",
|
||||
"00000000-0000-4000-8000-000000000213",
|
||||
"00000000-0000-4000-8000-000000000214",
|
||||
"00000000-0000-4000-8000-000000000215",
|
||||
];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
idFactory: () => ids.shift()!,
|
||||
});
|
||||
|
||||
try {
|
||||
const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const protectedAgent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const internal = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, internal: true },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
client.sessions[3]!.pushEvent({
|
||||
type: "turn_started",
|
||||
provider: "codex",
|
||||
turnId: "autonomous-running",
|
||||
});
|
||||
client.sessions[4]!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "autonomous-failed",
|
||||
error: "provider failed",
|
||||
});
|
||||
await manager.flush();
|
||||
|
||||
const recentSweep = await manager.collectIdleAgents({
|
||||
cutoff: new Date(recent.updatedAt.getTime() - 1),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
const protectedSweep = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set([protectedAgent.id, recent.id]),
|
||||
});
|
||||
|
||||
expect(recentSweep.collected).toEqual([]);
|
||||
expect(protectedSweep.collected).toEqual([]);
|
||||
expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(running.id)?.lifecycle).toBe("running");
|
||||
expect(manager.getAgent(failed.id)?.lifecycle).toBe("error");
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents protects an idle parent with a running managed child", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-child-"));
|
||||
const client = new SessionRecordingAgentClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
|
||||
try {
|
||||
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
client.sessions[1]!.pushEvent({
|
||||
type: "turn_started",
|
||||
provider: "codex",
|
||||
turnId: "managed-child-running",
|
||||
});
|
||||
await manager.flush();
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
|
||||
expect(collection).toEqual({
|
||||
collected: [expect.objectContaining({ agentId: independent.id })],
|
||||
failures: [],
|
||||
});
|
||||
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(child.id)?.lifecycle).toBe("running");
|
||||
expect(manager.getAgent(independent.id)).toBeNull();
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("collectIdleAgents protects an idle parent with a running provider subagent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-provider-child-"));
|
||||
const client = new SessionRecordingAgentClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
|
||||
try {
|
||||
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
client.sessions[0]!.pushEvent({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "provider-child-running",
|
||||
title: "Provider child",
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
await manager.flush();
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
|
||||
expect(collection).toEqual({
|
||||
collected: [expect.objectContaining({ agentId: independent.id })],
|
||||
failures: [],
|
||||
});
|
||||
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
|
||||
expect(manager.getAgent(independent.id)).toBeNull();
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("closed provider subagents do not block collection after resume", async () => {
|
||||
test("explicit close cancels running provider subagents before resume", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new SessionRecordingAgentClient();
|
||||
@@ -7811,11 +7586,6 @@ test("closed provider subagents do not block collection after resume", async ()
|
||||
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
|
||||
"completed",
|
||||
);
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
expect(collection.collected).toEqual([expect.objectContaining({ agentId: parent.id })]);
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
@@ -7825,8 +7595,8 @@ test("closed provider subagents do not block collection after resume", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("load waits for an in-flight collection close and creates only one resumed runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-"));
|
||||
test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const closeStarted = deferred<void>();
|
||||
const closeAllowed = deferred<void>();
|
||||
@@ -7858,10 +7628,7 @@ test("load waits for an in-flight collection close and creates only one resumed
|
||||
"00000000-0000-4000-8000-000000000216",
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const collection = manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
const close = manager.closeAgent(created.id);
|
||||
await closeStarted.promise;
|
||||
const loads = Promise.all([
|
||||
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
|
||||
@@ -7871,18 +7638,58 @@ test("load waits for an in-flight collection close and creates only one resumed
|
||||
expect(client.resumeCount).toBe(0);
|
||||
closeAllowed.resolve();
|
||||
const [first, second] = await loads;
|
||||
await collection;
|
||||
await close;
|
||||
|
||||
expect(first.id).toBe(created.id);
|
||||
expect(second.id).toBe(created.id);
|
||||
expect(client.resumeCount).toBe(1);
|
||||
} finally {
|
||||
closeAllowed.resolve();
|
||||
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent explicit closes tear down the runtime once", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-"));
|
||||
const closeStarted = deferred<void>();
|
||||
const closeAllowed = deferred<void>();
|
||||
let closeCount = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const recordClose = () => {
|
||||
closeCount += 1;
|
||||
};
|
||||
return new (class extends TestAgentSession {
|
||||
override async close(): Promise<void> {
|
||||
recordClose();
|
||||
closeStarted.resolve();
|
||||
await closeAllowed.promise;
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const firstClose = manager.closeAgent(agent.id);
|
||||
await closeStarted.promise;
|
||||
const secondClose = manager.closeAgent(agent.id);
|
||||
|
||||
closeAllowed.resolve();
|
||||
await Promise.all([firstClose, secondClose]);
|
||||
|
||||
expect(closeCount).toBe(1);
|
||||
} finally {
|
||||
closeAllowed.resolve();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider close failure still persists and emits a resumable closed agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
@@ -7905,18 +7712,8 @@ test("provider close failure still persists and emits a resumable closed agent",
|
||||
);
|
||||
const closed = waitForAgentLifecycle(manager, created.id, "closed");
|
||||
|
||||
const collection = await manager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
|
||||
await closed;
|
||||
expect(collection.collected).toEqual([]);
|
||||
expect(collection.failures).toHaveLength(1);
|
||||
expect(collection.failures[0]).toMatchObject({
|
||||
agentId: created.id,
|
||||
provider: "codex",
|
||||
error: expect.objectContaining({ message: "provider cleanup failed" }),
|
||||
});
|
||||
const stored = await storage.get(created.id);
|
||||
expect(stored).toMatchObject({ lastStatus: "closed" });
|
||||
expect(stored?.archivedAt).toBeFalsy();
|
||||
|
||||
@@ -394,21 +394,6 @@ export interface AgentMetricsSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionEntry {
|
||||
agentId: string;
|
||||
provider: AgentProvider;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry {
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface IdleAgentCollectionResult {
|
||||
collected: IdleAgentCollectionEntry[];
|
||||
failures: IdleAgentCollectionFailure[];
|
||||
}
|
||||
|
||||
type ActiveManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
@@ -969,15 +954,6 @@ export class AgentManager {
|
||||
return agent ? { ...agent } : null;
|
||||
}
|
||||
|
||||
touchAgentActivity(id: string): ManagedAgent | null {
|
||||
const agent = this.agents?.get(id);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
return { ...agent };
|
||||
}
|
||||
|
||||
async waitForAgentClose(agentId: string): Promise<void> {
|
||||
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
|
||||
}
|
||||
@@ -1436,66 +1412,6 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async collectIdleAgents(options: {
|
||||
cutoff: Date;
|
||||
protectedAgentIds: ReadonlySet<string>;
|
||||
}): Promise<IdleAgentCollectionResult> {
|
||||
const result: IdleAgentCollectionResult = { collected: [], failures: [] };
|
||||
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
const current = this.agents.get(agent.id);
|
||||
if (!current || !this.isIdleAgentCollectable(current, options)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: IdleAgentCollectionEntry = {
|
||||
agentId: current.id,
|
||||
provider: current.provider,
|
||||
...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}),
|
||||
};
|
||||
try {
|
||||
await this.closeAgent(current.id);
|
||||
result.collected.push(entry);
|
||||
} catch (error) {
|
||||
result.failures.push({ ...entry, error });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private isIdleAgentCollectable(
|
||||
agent: LiveManagedAgent,
|
||||
options: { cutoff: Date; protectedAgentIds: ReadonlySet<string> },
|
||||
): agent is ManagedAgentIdle {
|
||||
return (
|
||||
agent.lifecycle === "idle" &&
|
||||
agent.updatedAt.getTime() <= options.cutoff.getTime() &&
|
||||
!agent.internal &&
|
||||
!options.protectedAgentIds.has(agent.id) &&
|
||||
agent.activeForegroundTurnId === null &&
|
||||
!this.runs.hasRun(agent.id) &&
|
||||
!agent.pendingReplacement &&
|
||||
agent.pendingPermissions.size === 0 &&
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
!this.hasRunningChild(agent.id)
|
||||
);
|
||||
}
|
||||
|
||||
private hasRunningChild(parentAgentId: string): boolean {
|
||||
for (const agent of this.agents.values()) {
|
||||
if (
|
||||
agent.lifecycle === "running" &&
|
||||
getParentAgentIdFromLabels(agent.labels) === parentAgentId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return this.providerSubagents
|
||||
.list(parentAgentId)
|
||||
.some((subagent) => subagent.status === "running");
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (!this.registry) {
|
||||
|
||||
@@ -416,7 +416,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
});
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5[1m]",
|
||||
"claude-opus-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-fable-5",
|
||||
@@ -439,7 +438,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
}
|
||||
|
||||
const defaultModel = models.find((m) => m.isDefault);
|
||||
expect(defaultModel?.id).toBe("claude-opus-5[1m]");
|
||||
expect(defaultModel?.id).toBe("claude-opus-5");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -461,7 +460,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5[1m]");
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
|
||||
@@ -32,24 +32,15 @@ export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
||||
|
||||
export const CLAUDE_MODEL_MANIFEST = [
|
||||
{
|
||||
id: "claude-opus-5[1m]",
|
||||
label: "Opus 5 1M",
|
||||
description: "Opus 5 with 1M context window",
|
||||
id: "claude-opus-5",
|
||||
label: "Opus 5",
|
||||
description: "Opus 5 · Latest release",
|
||||
defaultPriority: 2,
|
||||
minimumClaudeCodeVersion: "2.1.219",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
label: "Opus 5",
|
||||
description: "Opus 5 · 200K context window",
|
||||
minimumClaudeCodeVersion: "2.1.219",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5[1m]",
|
||||
label: "Fable 5 1M",
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5[1m]",
|
||||
"claude-opus-5",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-fable-5",
|
||||
@@ -72,7 +71,7 @@ describe("getClaudeModels", () => {
|
||||
const models = getClaudeModels();
|
||||
const defaults = models.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0].id).toBe("claude-opus-5[1m]");
|
||||
expect(defaults[0].id).toBe("claude-opus-5");
|
||||
});
|
||||
|
||||
it("defines context window sizes in the catalog", () => {
|
||||
@@ -82,8 +81,7 @@ describe("getClaudeModels", () => {
|
||||
|
||||
expect(contextWindows).toEqual(
|
||||
new Map([
|
||||
["claude-opus-5[1m]", 1_000_000],
|
||||
["claude-opus-5", 200_000],
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
["claude-fable-5", 200_000],
|
||||
["claude-opus-4-8[1m]", 1_000_000],
|
||||
@@ -103,10 +101,8 @@ describe("getClaudeModels", () => {
|
||||
|
||||
it("filters models by their minimum Claude Code version", () => {
|
||||
const oldVersionModels = getClaudeModels("2.1.218");
|
||||
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
|
||||
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5");
|
||||
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
|
||||
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5[1m]");
|
||||
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
|
||||
|
||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]");
|
||||
@@ -347,7 +343,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
it("returns exact match for known model IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
||||
@@ -366,7 +361,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724[1m]")).toBe("claude-opus-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
|
||||
"claude-sonnet-5[1m]",
|
||||
@@ -374,7 +368,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
});
|
||||
|
||||
it("preserves [1m] suffix from runtime model strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
@@ -421,6 +414,32 @@ describe("findClaudeModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Opus 5 catalog", () => {
|
||||
it("offers a single Opus 5 entry with a 1M context window", () => {
|
||||
const opus5Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-opus-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(opus5Models).toEqual([
|
||||
{ id: "claude-opus-5", label: "Opus 5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves retired and dated Opus 5 IDs to the single catalog entry", () => {
|
||||
expect(findClaudeModel("claude-opus-5[1m]")?.id).toBe("claude-opus-5");
|
||||
expect(findClaudeModel("claude-opus-5-20260724")?.id).toBe("claude-opus-5");
|
||||
expect(findClaudeModel("claude-opus-5-20260724[1m]")?.id).toBe("claude-opus-5");
|
||||
expect(findClaudeModel("claude-opus-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => {
|
||||
expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({
|
||||
supported: true,
|
||||
fallbackThinkingOptionId: "low",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
|
||||
@@ -271,14 +271,19 @@ async function listCommandsFromFakeCodex(skills: unknown[]): Promise<AgentSlashC
|
||||
`
|
||||
let buffer = "";
|
||||
|
||||
function resultFor(method) {
|
||||
function resultFor(method, params) {
|
||||
if (method === "initialize") return {};
|
||||
if (method === "collaborationMode/list") return { data: [] };
|
||||
if (method === "skills/list") {
|
||||
const cwds = params && params.cwds;
|
||||
const projectCwd = "/tmp/codex-question-test";
|
||||
if (!Array.isArray(cwds) || cwds.length !== 1 || cwds[0] !== projectCwd) {
|
||||
return { data: [] };
|
||||
}
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
cwd: "/tmp/codex-question-test",
|
||||
cwd: projectCwd,
|
||||
skills: ${JSON.stringify(skills)},
|
||||
errors: [],
|
||||
},
|
||||
@@ -299,7 +304,7 @@ process.stdin.on("data", (chunk) => {
|
||||
const message = JSON.parse(line);
|
||||
if (typeof message.id !== "number") continue;
|
||||
try {
|
||||
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
|
||||
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method, message.params) }) + "\\n");
|
||||
} catch (error) {
|
||||
process.stdout.write(JSON.stringify({ id: message.id, error: { message: error.message } }) + "\\n");
|
||||
}
|
||||
@@ -1498,6 +1503,23 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
name: "project-skill-discovery-regression",
|
||||
description: "A skill discovered from this project.",
|
||||
path: "/tmp/codex-question-test/.agents/skills/project-skill-discovery-regression/SKILL.md",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
name: "project-skill-discovery-regression",
|
||||
description: "A skill discovered from this project.",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
@@ -3892,8 +3914,8 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
id: "dismiss",
|
||||
label: "Dismiss",
|
||||
behavior: "deny",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -3957,6 +3979,216 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces a pending synthetic plan approval when a later plan completes", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-first" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the first plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-second" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the revised plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "plan",
|
||||
input: { plan: "- Implement the revised plan" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") return {};
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("Revise the plan to include tests");
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(events).toContainEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: pendingPlan!.id,
|
||||
resolution: {
|
||||
behavior: "deny",
|
||||
message: "Dismissed by a new prompt",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
let continuePromptSetup: (() => void) | undefined;
|
||||
let markPromptSetupStarted: (() => void) | undefined;
|
||||
const promptSetupStarted = new Promise<void>((resolve) => {
|
||||
markPromptSetupStarted = resolve;
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
markPromptSetupStarted?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
continuePromptSetup = resolve;
|
||||
});
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") return {};
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
const startTurn = session.startTurn("Revise the plan");
|
||||
await promptSetupStarted;
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
await expect(
|
||||
session.respondToPermission(pendingPlan!.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`No pending Codex app-server permission request with id '${pendingPlan!.id}'`,
|
||||
);
|
||||
|
||||
continuePromptSetup?.();
|
||||
await startTurn;
|
||||
});
|
||||
|
||||
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
let acceptPrompt: (() => void) | undefined;
|
||||
let markPromptRequested: (() => void) | undefined;
|
||||
const promptRequested = new Promise<void>((resolve) => {
|
||||
markPromptRequested = resolve;
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") {
|
||||
markPromptRequested?.();
|
||||
return await new Promise<void>((resolve) => {
|
||||
acceptPrompt = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
const startTurn = session.startTurn("Revise the plan");
|
||||
await promptRequested;
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the newer plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
acceptPrompt?.();
|
||||
await startTurn;
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({ input: { plan: "- Implement the newer plan" } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") throw new Error("Prompt rejected");
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Revise the plan")).rejects.toThrow("Prompt rejected");
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits imageView paths with spaces as valid assistant markdown images", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -963,8 +963,8 @@ function buildPlanPermissionActions(options?: {
|
||||
}): AgentPermissionAction[] {
|
||||
const actions: AgentPermissionAction[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
id: "dismiss",
|
||||
label: "Dismiss",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
@@ -3092,6 +3092,13 @@ interface CodexSubAgentCallState {
|
||||
childThreadIds: Set<string>;
|
||||
}
|
||||
|
||||
interface CodexPendingPermissionHandler {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
|
||||
export class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -3115,15 +3122,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||
private pendingPermissionHandlers = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
>();
|
||||
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
private pendingAgentMessages = new Map<string, string>();
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
@@ -3306,7 +3305,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
try {
|
||||
const response = toObjectRecord(
|
||||
await this.client.request("skills/list", {
|
||||
cwd: [this.config.cwd],
|
||||
cwds: [this.config.cwd],
|
||||
}),
|
||||
);
|
||||
const entries = Array.isArray(response?.data) ? response.data : [];
|
||||
@@ -3430,6 +3429,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||
this.dismissPendingPlanApprovals("Superseded by a newer plan");
|
||||
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
@@ -3837,30 +3838,31 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.currentTurnId = null;
|
||||
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.currentTurnId = null;
|
||||
|
||||
this.logTurnStartSummary({
|
||||
turnId,
|
||||
thinkingOptionId: turnStart.thinkingOptionId,
|
||||
@@ -3871,13 +3873,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
||||
return { turnId };
|
||||
} catch (error) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
||||
@@ -4128,12 +4129,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private handlePlanPermissionResponse(params: {
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
pending: {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
};
|
||||
pending: CodexPendingPermissionHandler;
|
||||
pendingRequest: AgentPermissionRequest | null;
|
||||
}): AgentPermissionResult | void {
|
||||
const { requestId, response, pending, pendingRequest } = params;
|
||||
@@ -4144,6 +4140,23 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
this.resolvePlanPermission(requestId, response);
|
||||
if (followUpPrompt) {
|
||||
return { followUpPrompt };
|
||||
}
|
||||
}
|
||||
|
||||
private dismissPendingPlanApprovals(message: string): void {
|
||||
const requestIds = Array.from(this.pendingPermissionHandlers)
|
||||
.filter(([, pending]) => pending.kind === "plan")
|
||||
.map(([requestId]) => requestId);
|
||||
|
||||
for (const requestId of requestIds) {
|
||||
this.resolvePlanPermission(requestId, { behavior: "deny", message });
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
@@ -4151,11 +4164,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: response,
|
||||
resolution,
|
||||
});
|
||||
if (followUpPrompt) {
|
||||
return { followUpPrompt };
|
||||
}
|
||||
}
|
||||
|
||||
private emitDeniedToolCallTimelineEvent(params: {
|
||||
|
||||
@@ -186,6 +186,40 @@ describe("OMP CLI runtime", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts model catalogs with null contextWindow from NVIDIA", async () => {
|
||||
const child = createOmpChild();
|
||||
replyToCommands(child, () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "nvidia",
|
||||
id: "minimaxai/minimax-m3",
|
||||
name: "MiniMax-M3",
|
||||
contextWindow: null,
|
||||
},
|
||||
{
|
||||
provider: "zai",
|
||||
id: "glm-5.2",
|
||||
name: "GLM-5.2",
|
||||
contextWindow: 131_072,
|
||||
},
|
||||
],
|
||||
}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getAvailableModels()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "nvidia",
|
||||
id: "minimaxai/minimax-m3",
|
||||
contextWindow: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "zai",
|
||||
id: "glm-5.2",
|
||||
contextWindow: 131_072,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("wraps OMP subagent RPC commands", async () => {
|
||||
const child = createOmpChild();
|
||||
const commands: Record<string, unknown>[] = [];
|
||||
|
||||
@@ -128,7 +128,7 @@ class OmpHostToolHarness {
|
||||
}
|
||||
|
||||
describe("OMP host tools", () => {
|
||||
test("serializes the caller-scoped Paseo catalog for set_host_tools", () => {
|
||||
test("marks every caller-scoped Paseo tool essential for direct invocation", () => {
|
||||
const catalog = createCatalog([
|
||||
{
|
||||
name: "create_agent",
|
||||
@@ -137,6 +137,11 @@ describe("OMP host tools", () => {
|
||||
inputSchema: { initialPrompt: z.string().describe("Prompt for the new agent.") },
|
||||
handler: async () => ({ content: [] }),
|
||||
},
|
||||
{
|
||||
name: "browser_list_tabs",
|
||||
description: "List browser tabs.",
|
||||
handler: async () => ({ content: [] }),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeOmpHostTools(catalog)).toEqual([
|
||||
@@ -144,8 +149,15 @@ describe("OMP host tools", () => {
|
||||
name: "create_agent",
|
||||
label: "Create agent",
|
||||
description: "Create a Paseo agent.",
|
||||
loadMode: "essential",
|
||||
parameters: expect.objectContaining({ type: "object", required: ["initialPrompt"] }),
|
||||
},
|
||||
{
|
||||
name: "browser_list_tabs",
|
||||
description: "List browser tabs.",
|
||||
loadMode: "essential",
|
||||
parameters: expect.objectContaining({ type: "object" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostTool
|
||||
const definition: OmpRpcHostToolDefinition = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
loadMode: "essential",
|
||||
parameters: serializePaseoToolInputParameters(tool),
|
||||
};
|
||||
if (tool.title) {
|
||||
|
||||
@@ -103,7 +103,7 @@ export const OmpModelSchema = z
|
||||
name: z.string().optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
thinking: OmpModelThinkingSchema.optional(),
|
||||
contextWindow: z.number().optional(),
|
||||
contextWindow: z.number().nullable().optional(),
|
||||
maxTokens: z.number().nullable().optional(),
|
||||
api: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
@@ -178,6 +178,7 @@ export const OmpRpcHostToolDefinitionSchema = z
|
||||
name: z.string(),
|
||||
label: z.string().optional(),
|
||||
description: z.string(),
|
||||
loadMode: z.enum(["essential", "discoverable"]).optional(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
hidden: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -37,19 +37,14 @@ describe("opencode agent commands E2E", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("listing commands resumes an idle-collected agent", async () => {
|
||||
test("listing commands resumes an explicitly closed agent", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("opencode"),
|
||||
cwd: "/tmp",
|
||||
title: "Collected OpenCode Commands Test Agent",
|
||||
title: "Closed OpenCode Commands Test Agent",
|
||||
});
|
||||
|
||||
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
expect(collection.failures).toEqual([]);
|
||||
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
|
||||
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
|
||||
|
||||
const result = await ctx.client.listCommands({ agentId: agent.id });
|
||||
|
||||
@@ -707,6 +707,62 @@ describe("PiRpcAgentSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("treats Pi's aborted terminal response as cancellation after an interrupt", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.abort = async () => {
|
||||
fakeSession.finishTurn({
|
||||
role: "assistant",
|
||||
provider: "openai-responses",
|
||||
model: "gpt-5.6-terra",
|
||||
responseId: "resp-aborted",
|
||||
stopReason: "aborted",
|
||||
errorMessage: "OpenAI Responses stream ended before a terminal response event",
|
||||
content: [],
|
||||
});
|
||||
};
|
||||
|
||||
const { turnId } = await session.startTurn("stop this turn");
|
||||
await session.interrupt();
|
||||
|
||||
await expect(events.nextTurnCancellation()).resolves.toEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "pi",
|
||||
reason: "interrupted",
|
||||
turnId,
|
||||
});
|
||||
});
|
||||
|
||||
test("suppresses late aborted terminal response arriving after interrupt resolves", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.abort = async () => {};
|
||||
|
||||
const { turnId } = await session.startTurn("stop this turn");
|
||||
await session.interrupt();
|
||||
|
||||
await expect(events.nextTurnCancellation()).resolves.toEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "pi",
|
||||
reason: "interrupted",
|
||||
turnId,
|
||||
});
|
||||
|
||||
fakeSession.finishTurn({
|
||||
role: "assistant",
|
||||
provider: "openai-responses",
|
||||
model: "gpt-5.6-terra",
|
||||
responseId: "resp-aborted",
|
||||
stopReason: "aborted",
|
||||
errorMessage: "OpenAI Responses stream ended before a terminal response event",
|
||||
content: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
(events as unknown as { events: AgentStreamEvent[] }).events.map((e) => e.type),
|
||||
).not.toContain("turn_failed");
|
||||
});
|
||||
|
||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
|
||||
@@ -849,6 +849,11 @@ function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
|
||||
return formatPiErrorMessage(latestAssistant);
|
||||
}
|
||||
|
||||
function isPiAbortedTerminalResponse(messages: PiAgentMessage[]): boolean {
|
||||
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
||||
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1241,6 +1246,11 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private state: PiSessionState;
|
||||
private readonly currentModeId: string | null;
|
||||
private closed = false;
|
||||
// Pi reports an aborted OpenAI Responses stream before the abort RPC resolves.
|
||||
// Keep the turn active until that RPC acknowledges the user-requested cancellation.
|
||||
private interruptingTurnId: string | null = null;
|
||||
private lastInterruptedTurnId: string | null = null;
|
||||
private interruptedTerminalError: { turnId: string; error: string } | null = null;
|
||||
|
||||
constructor(options: PiRpcAgentSessionOptions) {
|
||||
this.runtimeSession = options.runtimeSession;
|
||||
@@ -1290,6 +1300,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
this.lastInterruptedTurnId = null;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeTurnStarted = false;
|
||||
@@ -1434,7 +1445,33 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
const turnId = this.activeTurnId;
|
||||
await this.runtimeSession.abort();
|
||||
if (turnId) {
|
||||
this.interruptingTurnId = turnId;
|
||||
this.lastInterruptedTurnId = turnId;
|
||||
}
|
||||
try {
|
||||
await this.runtimeSession.abort();
|
||||
} catch (error) {
|
||||
if (this.interruptingTurnId === turnId) {
|
||||
this.interruptingTurnId = null;
|
||||
}
|
||||
if (this.interruptedTerminalError?.turnId === turnId) {
|
||||
const terminalError = this.interruptedTerminalError;
|
||||
this.interruptedTerminalError = null;
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeTurnStarted = false;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.clearNoTurnBuffers();
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
error: terminalError.error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (turnId && this.activeTurnId === turnId) {
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
@@ -1448,6 +1485,12 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
if (this.interruptingTurnId === turnId) {
|
||||
this.interruptingTurnId = null;
|
||||
}
|
||||
if (this.interruptedTerminalError?.turnId === turnId) {
|
||||
this.interruptedTerminalError = null;
|
||||
}
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
@@ -2246,6 +2289,20 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
|
||||
if (turnId && this.interruptingTurnId === turnId && isPiAbortedTerminalResponse(messages)) {
|
||||
this.interruptedTerminalError = {
|
||||
turnId,
|
||||
error: latestPiErrorMessage(messages) ?? "Pi turn failed",
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isPiAbortedTerminalResponse(messages) &&
|
||||
(turnId === this.lastInterruptedTurnId || (!turnId && this.lastInterruptedTurnId !== null))
|
||||
) {
|
||||
this.lastInterruptedTurnId = null;
|
||||
return;
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
|
||||
@@ -215,8 +215,6 @@ import { DaemonExecutions } from "./hub/daemon-executions.js";
|
||||
|
||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||
const REDACTED_LOG_VALUE = "[redacted]";
|
||||
const IDLE_AGENT_RUNTIME_TTL_MS = 30 * 60 * 1000;
|
||||
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
|
||||
const DOWNLOAD_OPEN_FLAGS =
|
||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
|
||||
@@ -1194,39 +1192,6 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
let inFlightIdleAgentCollection: Promise<void> | null = null;
|
||||
const collectIdleAgentRuntimes = async () => {
|
||||
const protectedAgentIds = await scheduleService.listActiveAgentTargetIds();
|
||||
const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS);
|
||||
const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds });
|
||||
for (const collected of result.collected) {
|
||||
logger.info(collected, "Collected idle agent runtime");
|
||||
}
|
||||
for (const failure of result.failures) {
|
||||
const { error, ...context } = failure;
|
||||
logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime");
|
||||
}
|
||||
};
|
||||
const runIdleAgentCollection = () => {
|
||||
if (inFlightIdleAgentCollection) {
|
||||
return;
|
||||
}
|
||||
const collection = collectIdleAgentRuntimes()
|
||||
.catch((error) => {
|
||||
logger.warn({ err: error }, "Idle agent runtime sweep failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightIdleAgentCollection === collection) {
|
||||
inFlightIdleAgentCollection = null;
|
||||
}
|
||||
});
|
||||
inFlightIdleAgentCollection = collection;
|
||||
};
|
||||
const idleAgentCollectionTimer = setInterval(
|
||||
runIdleAgentCollection,
|
||||
IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS,
|
||||
);
|
||||
idleAgentCollectionTimer.unref();
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1634,8 +1599,6 @@ export async function createPaseoDaemon(
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
clearInterval(idleAgentCollectionTimer);
|
||||
await inFlightIdleAgentCollection;
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
agentManager.prepareForShutdown();
|
||||
|
||||
@@ -646,7 +646,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
"resumed Pi prompts retain their exact native entry ids after idle collection",
|
||||
"resumed Pi prompts retain their exact native entry ids after explicit runtime close",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-resumed-entry-id-");
|
||||
const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok";
|
||||
@@ -665,12 +665,7 @@ test(
|
||||
const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(firstFinish.status).toBe("idle");
|
||||
|
||||
const collection = await daemon.daemon.agentManager.collectIdleAgents({
|
||||
cutoff: new Date(Date.now() + 1_000),
|
||||
protectedAgentIds: new Set(),
|
||||
});
|
||||
expect(collection.failures).toEqual([]);
|
||||
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
|
||||
await daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
|
||||
await client.sendMessage(agent.id, secondPrompt);
|
||||
const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
||||
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
@@ -36,6 +37,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
@@ -98,6 +100,7 @@ export async function createPaseoWorktree(
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
@@ -376,47 +376,6 @@ describe("ScheduleService", () => {
|
||||
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
|
||||
});
|
||||
|
||||
test("lists only active schedules that target existing agents", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
const activeAgentId = "00000000-0000-4000-8000-000000000201";
|
||||
const pausedAgentId = "00000000-0000-4000-8000-000000000202";
|
||||
const completedAgentId = "00000000-0000-4000-8000-000000000203";
|
||||
const cadence = { type: "every" as const, everyMs: 60_000 };
|
||||
|
||||
await service.create({
|
||||
prompt: "Keep active agent resident",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: activeAgentId },
|
||||
});
|
||||
const paused = await service.create({
|
||||
prompt: "Paused heartbeat",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: pausedAgentId },
|
||||
});
|
||||
await service.pause(paused.id);
|
||||
await service.create({
|
||||
prompt: "Completed heartbeat",
|
||||
cadence,
|
||||
target: { type: "agent", agentId: completedAgentId },
|
||||
});
|
||||
await service.completeForAgent(completedAgentId);
|
||||
await service.create({
|
||||
prompt: "Fresh agent each run",
|
||||
cadence,
|
||||
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
|
||||
});
|
||||
|
||||
await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId]));
|
||||
});
|
||||
|
||||
test("completes schedules when max runs is reached", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
|
||||
@@ -204,7 +204,6 @@ type ScheduleAgentManager = Pick<
|
||||
| "hydrateTimelineFromProvider"
|
||||
| "resumeAgentFromPersistence"
|
||||
| "runAgent"
|
||||
| "touchAgentActivity"
|
||||
| "waitForAgentEvent"
|
||||
| "waitForAgentClose"
|
||||
>;
|
||||
@@ -370,17 +369,6 @@ export class ScheduleService {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
async listActiveAgentTargetIds(): Promise<Set<string>> {
|
||||
const schedules = await this.store.list();
|
||||
const agentIds = new Set<string>();
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.status === "active" && schedule.target.type === "agent") {
|
||||
agentIds.add(schedule.target.agentId);
|
||||
}
|
||||
}
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
async inspect(id: string): Promise<StoredSchedule> {
|
||||
const schedule = await this.store.get(id);
|
||||
if (!schedule) {
|
||||
|
||||
@@ -4906,7 +4906,7 @@ describe("agent config setters", () => {
|
||||
} {
|
||||
return {
|
||||
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
|
||||
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })),
|
||||
getAgent: vi.fn(() => ({ id: "agent-1" })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -745,6 +745,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("set mode: a failed load rejects without mutating the collected agent", async () => {
|
||||
test("set mode: a failed load rejects without mutating the closed agent", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
operations.loadFailure = new Error("agent is archived");
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface AgentConfigSessionHost {
|
||||
|
||||
/**
|
||||
* The per-agent config mutations this subsystem drives. The shell adapts these
|
||||
* onto the AgentManager and loads a collected agent before mutation (mode still
|
||||
* onto the AgentManager and loads a closed agent before mutation (mode still
|
||||
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
||||
* thinking yield a provider notice; model and feature do not.
|
||||
*/
|
||||
|
||||
@@ -76,6 +76,7 @@ function createHarness(input?: {
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -135,6 +136,7 @@ describe("workspace recovery", () => {
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -170,7 +172,9 @@ describe("workspace recovery", () => {
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -189,6 +193,15 @@ describe("workspace recovery", () => {
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
expect(warnings).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
materialized: 0,
|
||||
skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })],
|
||||
}),
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
@@ -220,6 +233,7 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
@@ -59,6 +60,7 @@ type RecoveryPlan =
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
logger: Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
@@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
if (result.worktreeIncludeSummary.skipped.length > 0) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
materialized: result.worktreeIncludeSummary.materialized,
|
||||
skipped: result.worktreeIncludeSummary.skipped,
|
||||
worktreePath: result.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
@@ -1540,6 +1540,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: true,
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
agentThinkingUpdate: true,
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: true,
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
import {
|
||||
resolveWorktreeCreationIntent,
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
repoRoot: string;
|
||||
created: boolean;
|
||||
@@ -120,15 +122,17 @@ export async function createWorktreeCore(
|
||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
||||
}
|
||||
|
||||
const { worktreeIncludeSummary, ...worktree } = await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
return {
|
||||
worktree: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
worktree,
|
||||
worktreeIncludeSummary,
|
||||
intent,
|
||||
repoRoot,
|
||||
created: true,
|
||||
|
||||
@@ -605,6 +605,17 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{
|
||||
materialized: createdWorktree.worktreeIncludeSummary.materialized,
|
||||
skipped: createdWorktree.worktreeIncludeSummary.skipped,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries",
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (input.firstAgentContext) {
|
||||
dependencies.autoNameWorkspaceBranchForFirstAgent({
|
||||
|
||||
@@ -21,6 +21,11 @@ const GrokUsageResponseSchema = z.object({
|
||||
val: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
used: z
|
||||
.object({
|
||||
val: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
usage: z
|
||||
@@ -30,13 +35,36 @@ const GrokUsageResponseSchema = z.object({
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const GrokAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GrokQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
/** Override home directory (tests). Production uses os.homedir(). */
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
/** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */
|
||||
export function extractGrokTokenFromAuth(auth: unknown): string | null {
|
||||
if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null;
|
||||
const record = auth as Record<string, unknown>;
|
||||
|
||||
const topLevel = record["access_token"];
|
||||
if (typeof topLevel === "string" && topLevel.length > 0) {
|
||||
return topLevel;
|
||||
}
|
||||
|
||||
const entries = Object.entries(record);
|
||||
const preferred = entries.filter(([key]) => key.startsWith("https://auth.x.ai::"));
|
||||
const candidates = preferred.length > 0 ? preferred : entries;
|
||||
|
||||
for (const [, value] of candidates) {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) continue;
|
||||
const nestedKey = (value as Record<string, unknown>)["key"];
|
||||
if (typeof nestedKey === "string" && nestedKey.length > 0) {
|
||||
return nestedKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
@@ -45,10 +73,12 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
private readonly homeDir: string | undefined;
|
||||
|
||||
constructor(options: GrokQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
this.homeDir = options.homeDir;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
@@ -76,7 +106,8 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const monthlyLimit = resp.config?.monthlyLimit?.val ?? null;
|
||||
const creditUsage = resp.usage?.creditUsage ?? null;
|
||||
// Live CLI billing uses config.used.val; older mocks used usage.creditUsage.
|
||||
const creditUsage = resp.config?.used?.val ?? resp.usage?.creditUsage ?? null;
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (monthlyLimit !== null || creditUsage !== null) {
|
||||
const remaining =
|
||||
@@ -107,11 +138,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
}
|
||||
|
||||
private async readGrokToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".grok", "auth.json");
|
||||
// homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE).
|
||||
const path = join(this.homeDir ?? homedir(), ".grok", "auth.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return auth.access_token ?? null;
|
||||
return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ function writeKimiCredentials(dir: string, accessToken: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
|
||||
mkdirSync(join(home, ".grok"), { recursive: true });
|
||||
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
|
||||
}
|
||||
|
||||
function writeMiniMaxConfig(dir: string, payload: Record<string, unknown>): void {
|
||||
mkdirSync(join(dir, ".mmx"), { recursive: true });
|
||||
writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload));
|
||||
@@ -382,7 +387,13 @@ describe("real provider usage fetchers", () => {
|
||||
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||
new GrokQuotaProvider({
|
||||
logger,
|
||||
fetch: fetchThroughTestDouble,
|
||||
// Match Kimi: inject temp HOME so nested auth-file tests work on Windows
|
||||
// (os.homedir() uses USERPROFILE there and ignores process.env.HOME).
|
||||
homeDir,
|
||||
}),
|
||||
new KimiQuotaProvider({
|
||||
logger,
|
||||
fetch: fetchThroughTestDouble,
|
||||
@@ -720,8 +731,7 @@ describe("real provider usage fetchers", () => {
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 0 } },
|
||||
usage: { creditUsage: 0 },
|
||||
config: { monthlyLimit: { val: 0 }, used: { val: 0 } },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
@@ -742,6 +752,109 @@ describe("real provider usage fetchers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok usage from live billing shape (config.used.val)", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: {
|
||||
monthlyLimit: { val: 150000 },
|
||||
used: { val: 37886 },
|
||||
billingPeriodStart: "2026-07-01T00:00:00+00:00",
|
||||
billingPeriodEnd: "2026-08-01T00:00:00+00:00",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 37886,
|
||||
remaining: 112114,
|
||||
limit: 150000,
|
||||
unit: "credits",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => {
|
||||
writeGrokAuth(homeDir, {
|
||||
"https://auth.x.ai::test-user-id": {
|
||||
key: "nested_jwt_token",
|
||||
refresh_token: "rt_nested",
|
||||
expires_at: "2026-08-01T00:00:00Z",
|
||||
user_id: "test-user-id",
|
||||
email: "user@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({
|
||||
config: {
|
||||
monthlyLimit: { val: 100 },
|
||||
used: { val: 25 },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(authorization).toBe("Bearer nested_jwt_token");
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 25,
|
||||
remaining: 75,
|
||||
limit: 100,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => {
|
||||
process.env["GROK_API_KEY"] = "grok_test_token";
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||
() =>
|
||||
jsonResponse({
|
||||
config: { monthlyLimit: { val: 50 } },
|
||||
usage: { creditUsage: 10 },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const grok = findProvider(await service().listUsage(), "grok");
|
||||
|
||||
expect(grok).toMatchObject({
|
||||
status: "available",
|
||||
balances: [
|
||||
expect.objectContaining({
|
||||
id: "monthly_credits",
|
||||
used: 10,
|
||||
remaining: 40,
|
||||
limit: 50,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Kimi usage from KIMI_TOKEN", async () => {
|
||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||
fetchApi = mockFetch(
|
||||
|
||||
@@ -253,7 +253,7 @@ function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>
|
||||
}
|
||||
|
||||
function removeZshShellIntegrationRuntimeDir(): void {
|
||||
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh`), {
|
||||
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
@@ -272,7 +272,9 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
expect(resolvedEnv.TERM).toBe("xterm-256color");
|
||||
expect(resolvedEnv.TERM_PROGRAM).toBe("kitty");
|
||||
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
|
||||
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir");
|
||||
expect(resolvedEnv.ZDOTDIR).toBe(
|
||||
join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`),
|
||||
);
|
||||
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
|
||||
expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -384,7 +384,7 @@ function resolveZshShellIntegrationRuntimeDir(): string {
|
||||
} catch {
|
||||
// keep fallback
|
||||
}
|
||||
return join(tmpdir(), `${username}-paseo-zsh`);
|
||||
return join(tmpdir(), `${username}-paseo-zsh-${process.pid}`);
|
||||
}
|
||||
|
||||
function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string {
|
||||
|
||||
635
packages/server/src/utils/worktree-include.test.ts
Normal file
635
packages/server/src/utils/worktree-include.test.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join, relative } from "path";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js";
|
||||
|
||||
describe("worktree include planning", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
mkdirSync(sourceRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies bare entries and accepts explicit copy and symlink modes", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env.local"), "source\n");
|
||||
mkdirSync(join(sourceRoot, ".cache"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n");
|
||||
mkdirSync(join(sourceRoot, "shared-state"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toHaveLength(4);
|
||||
expect(plan.materializations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }),
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }),
|
||||
expect.objectContaining({
|
||||
mode: "symlink",
|
||||
relativePath: "shared-state",
|
||||
sourceKind: "directory",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "packages/api/.runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid and conflicting entries while retaining safe entries", async () => {
|
||||
writeFileSync(join(sourceRoot, "shared"), "source\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }),
|
||||
expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing entries while retaining existing literal and glob matches", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "config/runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: ".env.local", reason: "missing" }),
|
||||
expect.objectContaining({ raw: "missing/**", reason: "missing" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the optimized trailing recursive form to resolve to a directory", async () => {
|
||||
writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("skips directory copies that overlap protected worktree paths", async () => {
|
||||
const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project");
|
||||
mkdirSync(protectedWorktreeRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [protectedWorktreeRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("protects every managed project beneath checkout-local worktree storage", async () => {
|
||||
const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees");
|
||||
const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling");
|
||||
mkdirSync(siblingWorktree, { recursive: true });
|
||||
writeFileSync(join(siblingWorktree, ".env"), "secret\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
".dev/paseo-home/worktrees/other-project/sibling/.env\n",
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("allows includes from a source worktree inside the protected worktree root", async () => {
|
||||
const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project");
|
||||
sourceRoot = join(managedWorktreesRoot, "source-worktree");
|
||||
mkdirSync(sourceRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips protected paths reached through a symlink alias",
|
||||
async () => {
|
||||
const sourceAlias = join(tempDir, "source-alias");
|
||||
symlinkSync(sourceRoot, sourceAlias, "dir");
|
||||
mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips external source links while retaining safe entries",
|
||||
async () => {
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
symlinkSync(outsidePath, join(sourceRoot, "linked.txt"));
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["safe.txt", "linked.txt", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => {
|
||||
mkdirSync(join(sourceRoot, "actual-config"));
|
||||
writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n");
|
||||
symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"does not scan unrelated directories for bounded globs",
|
||||
async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
const unreadableDirectory = join(sourceRoot, "unrelated");
|
||||
mkdirSync(unreadableDirectory);
|
||||
chmodSync(unreadableDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/api/.runtime.env" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(unreadableDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"skips inaccessible entries while retaining safe paths",
|
||||
async () => {
|
||||
const inaccessibleDirectory = join(sourceRoot, "private");
|
||||
mkdirSync(inaccessibleDirectory);
|
||||
writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["private/**", "private/*", "safe.txt", ""].join("\n"),
|
||||
);
|
||||
chmodSync(inaccessibleDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
expect.objectContaining({ raw: "private/*", reason: "materialization" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
chmodSync(inaccessibleDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"retains valid glob matches when a viable sibling is unreadable",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true });
|
||||
mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n");
|
||||
writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/good/.runtime.env" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("worktree include materialization", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies snapshots, links shared paths, and is idempotent", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n");
|
||||
mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n");
|
||||
mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true);
|
||||
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n");
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n");
|
||||
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe(
|
||||
"linked-dir-v2\n",
|
||||
);
|
||||
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n");
|
||||
});
|
||||
|
||||
it("replaces an existing directory snapshot without retaining destination-only files", async () => {
|
||||
mkdirSync(join(sourceRoot, "cache"));
|
||||
writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
mkdirSync(join(worktreeRoot, "cache"));
|
||||
writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n");
|
||||
expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("retains an explicit descendant when an overlapping directory copy is skipped", async () => {
|
||||
mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.env"), "local\n");
|
||||
writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n");
|
||||
mkdirSync(join(worktreeRoot, "config"));
|
||||
writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/**", reason: "conflict" }),
|
||||
]);
|
||||
expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n");
|
||||
});
|
||||
|
||||
it("skips a destination parent symlink without writing through it", async () => {
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n");
|
||||
|
||||
const outsideRoot = join(tempDir, "outside");
|
||||
mkdirSync(outsideRoot);
|
||||
symlinkSync(outsideRoot, join(worktreeRoot, "config"));
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(outsideRoot, "local.json"))).toBe(false);
|
||||
expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n");
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/local.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(readdirSync(worktreeRoot)).not.toContain(
|
||||
expect.stringMatching(/^\.paseo-worktreeinclude-/),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies resolved source links and creates direct live links", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const targetDirectoryPath = join(sourceRoot, "target-directory");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
mkdirSync(targetDirectoryPath);
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n");
|
||||
symlinkSync(targetPath, join(sourceRoot, "copy-link.txt"));
|
||||
symlinkSync(targetPath, join(sourceRoot, "live-link.txt"));
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir");
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[
|
||||
"copy-link.txt",
|
||||
"symlink live-link.txt",
|
||||
"copy-directory-link",
|
||||
"symlink live-directory-link",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "copy-link.txt");
|
||||
const linkedPath = join(worktreeRoot, "live-link.txt");
|
||||
const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link");
|
||||
const linkedDirectoryPath = join(worktreeRoot, "live-directory-link");
|
||||
const canonicalWorktreeRoot = realpathSync(worktreeRoot);
|
||||
expect(result).toMatchObject({ materialized: 4, skipped: [] });
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(linkedPath)).toBe(
|
||||
relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)),
|
||||
);
|
||||
expect(readlinkSync(linkedDirectoryPath)).toBe(
|
||||
relative(
|
||||
dirname(join(canonicalWorktreeRoot, "live-directory-link")),
|
||||
realpathSync(targetDirectoryPath),
|
||||
),
|
||||
);
|
||||
expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath));
|
||||
expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath));
|
||||
|
||||
writeFileSync(targetPath, "v2\n");
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n");
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(linkedPath, "utf8")).toBe("v2\n");
|
||||
expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n");
|
||||
});
|
||||
|
||||
it("rejects source links that alias Git metadata", async () => {
|
||||
mkdirSync(join(sourceRoot, ".git"));
|
||||
writeFileSync(join(sourceRoot, ".git", "config"), "secret\n");
|
||||
symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("treats hard links as ordinary files", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const hardLinkPath = join(sourceRoot, "hard-link.txt");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
linkSync(targetPath, hardLinkPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "hard-link.txt");
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
});
|
||||
|
||||
it("skips a source link retargeted outside the checkout after planning", async () => {
|
||||
const insidePath = join(sourceRoot, "inside.txt");
|
||||
const linkedPath = join(sourceRoot, "linked.txt");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(insidePath, "inside\n");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(insidePath, linkedPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(linkedPath);
|
||||
symlinkSync(outsidePath, linkedPath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a linked directory that exposes an external nested link", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
mkdirSync(sharedPath);
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(outsidePath, join(sharedPath, "outside.txt"));
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows a linked directory with internal nested links", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const targetPath = join(sourceRoot, "shared-target");
|
||||
mkdirSync(sharedPath);
|
||||
mkdirSync(targetPath);
|
||||
writeFileSync(join(targetPath, "state.txt"), "source\n");
|
||||
symlinkSync(targetPath, join(sharedPath, "target"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe(
|
||||
"source\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a source removed after planning", async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n");
|
||||
const sourcePath = join(sourceRoot, "runtime.env");
|
||||
writeFileSync(sourcePath, "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(sourcePath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "runtime.env", reason: "missing" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"skips ordinary filesystem failures during materialization revalidation",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "private"));
|
||||
writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n");
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
chmodSync(join(sourceRoot, "private"), 0o000);
|
||||
|
||||
try {
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(result).toMatchObject({ materialized: 0 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses a live directory link and removes it without touching the source", async () => {
|
||||
mkdirSync(join(sourceRoot, "shared-state"));
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe(
|
||||
"source-v2\n",
|
||||
);
|
||||
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n");
|
||||
});
|
||||
});
|
||||
1359
packages/server/src/utils/worktree-include.ts
Normal file
1359
packages/server/src/utils/worktree-include.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import {
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
} from "fs";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
@@ -362,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("removes fetched branches when include planning fails", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
|
||||
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
||||
.toString()
|
||||
.trim();
|
||||
execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
|
||||
mkdirSync(join(repoDir, ".worktreeinclude"));
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "pr-44-cleanup",
|
||||
source: {
|
||||
kind: "checkout-github-pr",
|
||||
githubPrNumber: 44,
|
||||
headRef: "contributor/cleanup",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "branch-cleanup",
|
||||
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
@@ -1062,6 +1125,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, ".gitignore"),
|
||||
[".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
"test -f .copy.env",
|
||||
"test -L linked-file.txt",
|
||||
"test -L linked-state",
|
||||
"cat linked-state/state.txt > setup.log",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n");
|
||||
mkdirSync(join(repoDir, "copy-cache"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n");
|
||||
mkdirSync(join(repoDir, "linked-state"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-links",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe(
|
||||
"linked-state-v1\n",
|
||||
);
|
||||
expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd: result.worktreePath,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toBe("");
|
||||
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n");
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n");
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe(
|
||||
"copy-cache-v1\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe(
|
||||
"linked-file-v2\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoDir,
|
||||
worktreePath: result.worktreePath,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(false);
|
||||
expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n");
|
||||
expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing includes and materializes paths that exist", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include");
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".env", ".env.local", ".sops.yaml", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "missing-include",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false);
|
||||
expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("skips checkout-local worktree storage and creates the remaining includes", async () => {
|
||||
const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home");
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(
|
||||
checkoutLocalPaseoHome,
|
||||
"worktrees",
|
||||
projectHash,
|
||||
"protected-include",
|
||||
);
|
||||
mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n"));
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "protected-include",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/protected-include",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: checkoutLocalPaseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/protected-include"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/protected-include");
|
||||
});
|
||||
|
||||
it("keeps includes when branching from a Paseo-managed worktree", async () => {
|
||||
writeFileSync(join(repoDir, ".gitignore"), ".env\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n");
|
||||
execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, ".env"), "source\n");
|
||||
|
||||
const sourceWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-source",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-source",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const nestedWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: sourceWorktree.worktreePath,
|
||||
worktreeSlug: "include-nested",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "feature/include-source",
|
||||
branchName: "feature/include-nested",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a symlink include conflict and keeps the new worktree", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict");
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} }));
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-conflict",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-conflict",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(existsSync(expectedWorktreePath)).toBe(true);
|
||||
expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false);
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/include-conflict"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/include-conflict");
|
||||
});
|
||||
|
||||
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
@@ -36,6 +36,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
materializeWorktreeIncludePlan,
|
||||
readWorktreeIncludePlan,
|
||||
type WorktreeIncludeSummary,
|
||||
} from "./worktree-include.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -49,6 +54,10 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||
}
|
||||
|
||||
export interface WorktreeRuntimeEnv {
|
||||
[key: string]: string;
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
@@ -1161,13 +1170,67 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
}
|
||||
|
||||
export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
}
|
||||
|
||||
async function removeCreatedWorktreeBranch(options: {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
cwd?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!options.createdBranchName || !options.cwd) {
|
||||
return;
|
||||
}
|
||||
if (!(await localBranchExists(options.cwd, options.createdBranchName))) {
|
||||
return;
|
||||
}
|
||||
if (options.expectedOid) {
|
||||
await runGitCommand(
|
||||
["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid],
|
||||
{ cwd: options.cwd },
|
||||
);
|
||||
} else {
|
||||
await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackCreatedWorktreeBranch(
|
||||
options: {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
cwd: string;
|
||||
},
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await removeCreatedWorktreeBranch(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
if (cleanupError) {
|
||||
const failure = new Error(
|
||||
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
||||
{ cause },
|
||||
);
|
||||
Object.assign(failure, { cleanupError });
|
||||
throw failure;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
options: RollbackCreatedPaseoWorktreeOptions,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
await removeCreatedWorktreeBranch(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
@@ -1233,50 +1296,100 @@ export const createWorktree = async ({
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
const { worktreeIncludePlan, worktreePath } = await (async () => {
|
||||
try {
|
||||
const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
|
||||
const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
|
||||
const includePlan = await readWorktreeIncludePlan({
|
||||
sourceRoot: cwd,
|
||||
excludedSourceRoots: [paseoWorktreesBaseRoot],
|
||||
});
|
||||
const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug);
|
||||
mkdirSync(dirname(requestedWorktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = requestedWorktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
return {
|
||||
worktreeIncludePlan: includePlan,
|
||||
worktreePath: normalizePathForOwnership(finalWorktreePath),
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{
|
||||
cwd,
|
||||
createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd,
|
||||
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
let worktreeIncludeSummary: WorktreeIncludeSummary = {
|
||||
materialized: 0,
|
||||
skipped: [...worktreeIncludePlan.skipped],
|
||||
};
|
||||
try {
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
const materialization = await materializeWorktreeIncludePlan({
|
||||
plan: worktreeIncludePlan,
|
||||
worktreeRoot: worktreePath,
|
||||
});
|
||||
worktreeIncludeSummary = {
|
||||
materialized: materialization.materialized,
|
||||
skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped],
|
||||
};
|
||||
} catch (error) {
|
||||
await rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome,
|
||||
worktreesBaseRoot: worktreesRoot,
|
||||
createdBranchName: sourcePlan.createdBranchName,
|
||||
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
@@ -1287,6 +1400,7 @@ export const createWorktree = async ({
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreeIncludeSummary,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
@@ -1299,6 +1413,9 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
createdBranchName?: string;
|
||||
createdBranchNameBeforeWorktreeAdd?: string;
|
||||
createdBranchOidBeforeWorktreeAdd?: string;
|
||||
metadataBaseRefName: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||
addArguments: string[];
|
||||
@@ -1314,6 +1431,11 @@ interface WorktreeSourcePlan {
|
||||
};
|
||||
}
|
||||
|
||||
type ChangeRequestWorktreeSource = Extract<
|
||||
WorktreeSource,
|
||||
{ kind: "checkout-change-request" | "checkout-github-pr" }
|
||||
>;
|
||||
|
||||
async function resolveWorktreeSourcePlan({
|
||||
cwd,
|
||||
source,
|
||||
@@ -1332,94 +1454,144 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
createdBranchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
await validateExistingWorktreeBranchName(cwd, source.branchName);
|
||||
if (!(await localBranchExists(cwd, source.branchName))) {
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(source.branchName);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
case "checkout-branch":
|
||||
return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName });
|
||||
case "checkout-change-request":
|
||||
case "checkout-github-pr": {
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
});
|
||||
const shouldTrackOriginHead = source.trackOriginHead === true;
|
||||
const trackingRemote = shouldTrackOriginHead
|
||||
? await tryFetchWorktreeTrackingRemote({
|
||||
cwd,
|
||||
remoteName: "origin",
|
||||
headRef: source.headRef,
|
||||
})
|
||||
: undefined;
|
||||
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
|
||||
if (source.pushRemoteUrl) {
|
||||
const remoteName = `paseo-pr-${changeRequestNumber}`;
|
||||
remotePlan.pushRemote = {
|
||||
name: remoteName,
|
||||
url: source.pushRemoteUrl,
|
||||
headRef: source.headRef,
|
||||
track: true,
|
||||
};
|
||||
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
|
||||
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
|
||||
if (originUrl) {
|
||||
remotePlan.pushRemote = {
|
||||
name: `paseo-pr-${changeRequestNumber}`,
|
||||
url: originUrl,
|
||||
headRef: source.headRef,
|
||||
track: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (trackingRemote) {
|
||||
remotePlan.trackingRemote = trackingRemote;
|
||||
}
|
||||
case "checkout-github-pr":
|
||||
return resolveChangeRequestWorktreeSourcePlan({ cwd, source });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner
|
||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
||||
: {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
async function resolveCheckoutBranchWorktreeSourcePlan(options: {
|
||||
branchName: string;
|
||||
cwd: string;
|
||||
}): Promise<WorktreeSourcePlan> {
|
||||
await validateExistingWorktreeBranchName(options.cwd, options.branchName);
|
||||
const needsFetch = !(await localBranchExists(options.cwd, options.branchName));
|
||||
let createdBranchOid: string | undefined;
|
||||
if (needsFetch) {
|
||||
try {
|
||||
createdBranchOid = await fetchNewLocalBranchAtomically({
|
||||
cwd: options.cwd,
|
||||
localBranchName: options.branchName,
|
||||
remoteName: "origin",
|
||||
remoteRef: `refs/heads/${options.branchName}`,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isBranchCheckedOut(options.cwd, options.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(options.branchName);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!needsFetch) {
|
||||
throw error;
|
||||
}
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{
|
||||
cwd: options.cwd,
|
||||
createdBranchName: options.branchName,
|
||||
expectedOid: createdBranchOid,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: options.branchName,
|
||||
...(needsFetch
|
||||
? {
|
||||
createdBranchName: options.branchName,
|
||||
createdBranchNameBeforeWorktreeAdd: options.branchName,
|
||||
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
|
||||
}
|
||||
: {}),
|
||||
metadataBaseRefName: options.branchName,
|
||||
addArguments: [options.branchName],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChangeRequestWorktreeSourcePlan(options: {
|
||||
cwd: string;
|
||||
source: ChangeRequestWorktreeSource;
|
||||
}): Promise<WorktreeSourcePlan> {
|
||||
const { cwd, source } = options;
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
|
||||
let createdBranchOid: string | undefined;
|
||||
try {
|
||||
createdBranchOid = await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
});
|
||||
const shouldTrackOriginHead = source.trackOriginHead === true;
|
||||
const trackingRemote = shouldTrackOriginHead
|
||||
? await tryFetchWorktreeTrackingRemote({
|
||||
cwd,
|
||||
remoteName: "origin",
|
||||
headRef: source.headRef,
|
||||
})
|
||||
: undefined;
|
||||
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
|
||||
if (source.pushRemoteUrl) {
|
||||
const remoteName = `paseo-pr-${changeRequestNumber}`;
|
||||
remotePlan.pushRemote = {
|
||||
name: remoteName,
|
||||
url: source.pushRemoteUrl,
|
||||
headRef: source.headRef,
|
||||
track: true,
|
||||
};
|
||||
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
|
||||
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
|
||||
if (originUrl) {
|
||||
remotePlan.pushRemote = {
|
||||
name: `paseo-pr-${changeRequestNumber}`,
|
||||
url: originUrl,
|
||||
headRef: source.headRef,
|
||||
track: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (trackingRemote) {
|
||||
remotePlan.trackingRemote = trackingRemote;
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
createdBranchName: localBranchName,
|
||||
createdBranchNameBeforeWorktreeAdd: localBranchName,
|
||||
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{ cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid },
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureWorktreePushRemote(options: {
|
||||
@@ -1471,27 +1643,22 @@ async function fetchWorktreeCheckoutRefs(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
checkoutRefs: WorktreeCheckoutRef[];
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
let lastResult:
|
||||
| Awaited<ReturnType<typeof runGitCommand>>
|
||||
| { stderr: string; stdout: string; exitCode: number | null }
|
||||
| null = null;
|
||||
for (const checkoutRef of options.checkoutRefs) {
|
||||
lastResult = await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
checkoutRef.remoteName ?? "origin",
|
||||
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
try {
|
||||
return await fetchNewLocalBranchAtomically({
|
||||
cwd: options.cwd,
|
||||
timeout: 120_000,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
},
|
||||
);
|
||||
if (lastResult.exitCode === 0) {
|
||||
return;
|
||||
localBranchName: options.localBranchName,
|
||||
remoteName: checkoutRef.remoteName ?? "origin",
|
||||
remoteRef: checkoutRef.remoteRef,
|
||||
});
|
||||
} catch (error) {
|
||||
lastResult =
|
||||
error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null;
|
||||
}
|
||||
}
|
||||
const attemptedRefs = options.checkoutRefs
|
||||
@@ -1502,6 +1669,35 @@ async function fetchWorktreeCheckoutRefs(options: {
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchNewLocalBranchAtomically(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
remoteName: string;
|
||||
remoteRef: string;
|
||||
}): Promise<string> {
|
||||
const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`;
|
||||
try {
|
||||
await runGitCommand(
|
||||
["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"],
|
||||
{ cwd: options.cwd, timeout: 120_000 },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
const oid = stdout.trim();
|
||||
const nullOid = "0".repeat(oid.length);
|
||||
await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return oid;
|
||||
} finally {
|
||||
await runGitCommand(["update-ref", "-d", temporaryRef], {
|
||||
cwd: options.cwd,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryFetchWorktreeTrackingRemote(options: {
|
||||
cwd: string;
|
||||
remoteName: string;
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
"setup": [
|
||||
"npm ci",
|
||||
"node ./scripts/seed-ios-native-cache.mjs",
|
||||
"PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PWD/.dev/paseo-home\" ./scripts/dev-home.sh",
|
||||
"node ./scripts/seed-worktree-dev-state.mjs",
|
||||
"npm run build:server",
|
||||
"npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PWD/packages/server/.env\""
|
||||
"npm run build --workspace=@getpaseo/expo-two-way-audio"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -110,6 +110,46 @@ Both fields accept a multiline shell script or an array of commands; commands ru
|
||||
|
||||
Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc).
|
||||
|
||||
## .worktreeinclude
|
||||
|
||||
Use a root-level .worktreeinclude to materialize local source-checkout files before
|
||||
worktree.setup runs. Each path is relative to the source checkout.
|
||||
|
||||
# Copy is the default.
|
||||
.env.local
|
||||
.cache/**
|
||||
|
||||
# Modes can also be explicit.
|
||||
symlink node_modules
|
||||
copy .tool-state/**
|
||||
|
||||
Each line is `[copy|symlink] <path>`; the mode is optional and defaults to `copy`. Blank lines
|
||||
and whole-line comments are ignored. A single star matches within a path segment and a double
|
||||
star matches recursively; a directory ending in /\*\* materializes that directory as one
|
||||
recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected.
|
||||
|
||||
Copy entries are independent snapshots: a copied file or directory replaces an existing path on
|
||||
a later materialization. Symlink entries point directly at
|
||||
the live source file or directory, so changes through either path affect the same data. Paseo
|
||||
does not replace an existing file, directory, or different link with a symlink.
|
||||
|
||||
Entries must resolve to regular files or directories. A top-level source symbolic link is
|
||||
dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots
|
||||
that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links
|
||||
so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its
|
||||
live source contents.
|
||||
|
||||
Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without
|
||||
a trailing slash (for example, node_modules, not node_modules/), because Git treats the link
|
||||
itself as a file. Unignored materialized paths appear in git status.
|
||||
|
||||
On Windows, Paseo uses junctions for local directories. File links and network-directory links
|
||||
require Windows symbolic-link support. It never silently copies an explicit `symlink <path>`
|
||||
entry; enable Developer Mode or switch that entry to `copy <path>` if link creation fails.
|
||||
|
||||
Archiving removes only the worktree's links, not their source targets. If the source path is
|
||||
later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically.
|
||||
|
||||
## Scripts and services
|
||||
|
||||
`scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user