Compare commits

...

49 Commits

Author SHA1 Message Date
Mohamed Boudra
58fc2bb434 chore(release): cut 0.1.70 2026-05-08 20:32:20 +07:00
Mohamed Boudra
6e75c72492 chore: refresh lockfile dev markers, document release gotcha
`release:prepare` runs `npm install` which churns optional-dep `dev`
markers in package-lock.json. That dirties the tree before the
`npm version` step in `version:all:*`, and the pre-commit format hook
rejects a lockfile-only commit because oxfmt internally skips
package-lock.json. Document the run-format/lint/typecheck-first
guardrail in docs/release.md.
2026-05-08 20:31:26 +07:00
Mohamed Boudra
3ed5567c8a docs(changelog): promote 0.1.70-beta.1 to 0.1.70 2026-05-08 20:21:47 +07:00
Mohamed Boudra
ad08868778 Fix Claude binary resolution for replace-mode command override
isAvailable() honored runtimeSettings.command.mode === "replace" but
resolveClaudeBinary() only looked for `claude` on PATH. Users with a
custom claude wrapper and no `claude` itself on PATH passed the
availability check and then failed at session creation with "Claude
binary not found".

resolveClaudeBinary now mirrors isAvailable: if the runtime command is
in replace mode, look up the override binary first.
2026-05-08 20:13:27 +07:00
Mohamed Boudra
5b6e974248 Fix terminal PTY teardown races 2026-05-08 20:02:52 +07:00
Mohamed Boudra
04b04fe5ee Fix terminal subscription snapshot ordering 2026-05-08 19:29:15 +07:00
Mohamed Boudra
9d5a47b1c3 Fix Claude integration query cleanup 2026-05-08 19:17:25 +07:00
Mohamed Boudra
5c90449707 Centralize Claude SDK launch path 2026-05-08 18:47:56 +07:00
Mohamed Boudra
e490bf1dab fix(server-tests): unblock Windows CI for Claude SDK + .exe launch test
- spawn.launch-regression: insert `--` between `node -e <body>` and the
  user JSON args. Without it Node treats `--config` as a CLI option and
  exits with code 9 ("bad option"). The `--` stops Node's flag parsing
  so userArgs land in process.argv intact.
- claude-agent.integration + claude-sdk-behavior: inject
  spawnClaudeCodeProcess that routes the SDK's launch through our
  spawnProcess (shell: false). On Windows the SDK's default spawn fails
  with EINVAL when pathToClaudeCodeExecutable resolves to a `.cmd` shim
  (CVE-2024-27980). This mirrors what claude-agent.ts does in production.
- 3 cleanup sites (event-stream, integration, sdk-behavior): swallow
  EBUSY/ENOTEMPTY/EPERM around rmSync. The maxRetries window doesn't
  reliably win the Windows cwd-lock race after Claude exits; the OS will
  reap the tmpdir.
2026-05-08 17:43:15 +07:00
Mohamed Boudra
b131876e69 test(server): cover .exe/.cmd/.bat end-to-end launch on Windows
Add three end-to-end tests in spawn.launch-regression.test.ts that exercise
findExecutable -> spawnProcess for each Windows-supported extension. Each
test creates a uniquely-named binary in a tempdir, prepends it to PATH,
verifies findExecutable returns the fixture path, then launches via
spawnProcess and asserts an exact JSON-arg roundtrip plus stdout/exit code.

The previous "does not detect a PowerShell shim from PATH" test is removed:
its expectation was satisfied via PATH-prepend (not isolation), so global
claude.CMD on CI made it return non-null. The supported-extension contract
matches which's hardcoded fallback (.EXE;.CMD;.BAT;.COM); .ps1 is not
supported by spawnProcess and not surfaced by which without explicit PATHEXT.

Also add maxRetries: 5 / retryDelay: 100 to rmSync cleanups in three Claude
integration tests to absorb the brief Windows cwd lock after Claude exits.
2026-05-08 17:24:52 +07:00
Mohamed Boudra
b7d2e6d3ec Revert "ci: gate Claude Code install to Linux server-tests"
This reverts commit 09ed46b997.
2026-05-08 16:28:49 +07:00
Mohamed Boudra
09ed46b997 ci: gate Claude Code install to Linux server-tests
Installing claude-code globally on the Windows server-tests runner
exposed 12 pre-existing Windows-specific test failures that were
silently skipped before (rmdir EBUSY during cleanup, .CMD spawn
EINVAL via the SDK, plus the launch-regression test that asserts
claude is *not* on PATH).

The Linux e2e tests (model-catalog, live-preferences, models) are
the only ones that needed the binary to flip isAvailable() true;
none of those run on Windows. Skip the install on Windows so those
Windows compatibility gaps stay surfaced separately and don't block
the SDK upgrade.
2026-05-08 16:26:43 +07:00
Mohamed Boudra
a4d365c8f2 fix server-ci: install only claude-code, set SDK path in contract test
Two regressions from the previous CI install:

- Installing opencode-ai globally in server-ci/server-tests caused
  opencode-agent.test.ts (gated on \`hasOpenCode\`) to start running
  for the first time and surface real but unrelated test breakage.
  Trim the install to claude-code, which is all that's needed for the
  Claude e2e tests that motivated the change.

- claude-agent.integration.test.ts's supportedModels test calls the
  SDK's \`query()\` directly (bypassing ClaudeAgentClient) and didn't
  pass \`pathToClaudeCodeExecutable\`. The 0.2.71 SDK had a bundled
  cli.js fallback; 0.2.133 doesn't, so on Ubuntu CI it tries the
  per-platform binary path (\`...claude-agent-sdk-linux-x64-musl/claude\`)
  which isn't installed via the \`claude-code\` global. Resolve the
  binary explicitly in the test and pass it as the SDK option.
2026-05-08 16:14:28 +07:00
Mohamed Boudra
13538dd710 ci: install claude-code globally in server-tests
server-tests in ci.yml and Server CI both need claude on PATH for the
e2e tests that probe model catalogs and live preferences. cli-tests
already installs the trio (claude-code, codex, opencode-ai); server
tests now match.
2026-05-08 16:05:30 +07:00
Mohamed Boudra
e60350be08 refactor(claude-agent): inject binary resolver via client constructor
The session previously called resolveClaudeBinary() directly in
buildOptions(), with no seam for tests. CI ran without claude on PATH,
so every test that exercised buildOptions hit the production throw.

Apply the ports-and-adapters pattern at the client boundary (the same
place tests already configure queryFactory):

- ClaudeAgentClient takes optional resolveBinary, defaults to
  resolveClaudeBinary which throws "Claude binary not found..." when
  the executable is missing — preserving production fail-fast.
- Client passes resolveBinary through to ClaudeAgentSession options.
- buildOptions calls this.resolveBinary() instead of importing the
  free function directly.
- Tests construct the public client with a stub resolver. No
  __claudeAgentInternals export, no reaching into the session.

User-facing fail-fast still lives where it always did — AgentManager
gates start/resume/draft flows on client.isAvailable(). The throw in
resolveClaudeBinary is defense-in-depth that mirrors Codex.
2026-05-08 15:55:14 +07:00
Mohamed Boudra
b0c36f2bab test: Claude isAvailable now reflects PATH binary, no SDK fallback 2026-05-08 15:28:46 +07:00
Mohamed Boudra
d708f099be fix(claude-agent): don't fail-fast in buildOptions
resolveClaudeBinary threw eagerly inside buildOptions, breaking unit tests
that mock the SDK at the module level (env, redesign, interrupt-restart-
regression suites). The user-facing fail-fast already lives in isAvailable();
in buildOptions, fall back to omitting pathToClaudeCodeExecutable so the SDK
surfaces its own error if the binary truly is missing.
2026-05-08 15:25:57 +07:00
Mohamed Boudra
cb2fe91998 fix CI fallout from claude-agent-sdk 0.2.133 upgrade
- mcp-server tests: MCP SDK 1.29 renamed RegisteredTool.callback → handler
- ci: install @anthropic-ai/claude-code globally for cli-tests, matching
  codex/opencode now that bundled fallback is gone
2026-05-08 15:18:24 +07:00
github-actions[bot]
d7820b7a84 fix: update lockfile signatures and Nix hash 2026-05-08 08:08:58 +00:00
Mohamed Boudra
fb0fbb79f6 upgrade claude-agent-sdk to 0.2.133, drop bundled binary
Bumps @anthropic-ai/claude-agent-sdk from ^0.2.71 to ^0.2.133. The new SDK
hardens handleControlRequest against post-close transport writes (v0.2.94+),
which fixes the unhandledRejection that crashed the daemon when the SDK fired
a fire-and-forget control_request after we'd torn down the transport.

The SDK now ships per-platform Claude Code binaries via optionalDependencies
(~210 MB each). Paseo requires user-installed `claude` on PATH instead — same
posture as Codex/OpenCode:

- after-pack now prunes @anthropic-ai/claude-agent-sdk-* from the Electron
  bundle, matching the existing per-platform pruning for onnxruntime, sharp,
  and node-pty.
- claude-agent.ts gains resolveClaudeBinary() (mirrors resolveCodexBinary):
  throws with install instructions when `claude` is missing, instead of
  silently falling back to the bundled binary.
- isAvailable() now checks isCommandAvailable("claude") instead of always
  returning true.
2026-05-08 15:07:40 +07:00
Mohamed Boudra
c97c000d9c paseo-epic: lock requirements early and gate on them
Adds a pre-start preferences step (onboards users with no
orchestration-preferences.json), a capture-intent step that runs
even under --autopilot, an immutable Requirements block in the plan
that includes acceptance criteria, and a final lightweight check
that audits every requirement before deliver. Drops --no-grill.
2026-05-08 11:46:45 +07:00
Mohamed Boudra
c4e4a28bc0 Run server tests on Linux+Windows matrix (#809)
* Run server tests on Linux+Windows matrix

Replace the hand-curated Windows server test allow-list with a full-suite matrix run.\nBoth Ubuntu and Windows now run the same server test command with shared setup and secrets.

* Add isPlatform helper and gate Windows-hostile server tests

- Add a shared server test isPlatform helper.

- Migrate existing Windows-gated spawn, worktree, executable, and worktree-core tests to the helper.

- Gate Windows-hostile symlink, macOS path-normalization, and POSIX shell setup tests with skipIf.

* Replace POSIX shell calls in test fixtures with Node primitives

- Replaced test fixture mkdir/echo shell setup with fs mkdirSync/writeFileSync calls in touched server tests.

- Replaced git shell strings with execFileSync/spawnSync argv calls across checkout, worktree, MCP, script, and workspace git fixtures.

- Gated the directory suggestion symlink escape tests on Windows because those fixtures require POSIX symlink behavior.

* Fix Windows server-test failures and split POSIX-only suites into sibling files

- Fix Windows path/cwd assertions in terminal, session/workspace, git-service, checkout-git, MCP, logger, spawn, and registry bootstrap tests.\n- Keep terminal tests runnable on Windows by canonicalizing temp cwd fixtures and ensuring a Windows shell fallback.\n- Gate POSIX-only shell, signal, Unix socket, and git-worktree reuse fixtures that need dedicated Windows coverage later.

* Move POSIX-only test blocks into sibling .posix.test.ts files

- terminal.test.ts: moved PTY/bash interaction blocks into terminal.posix.test.ts.

- worktree.test.ts: moved git-worktree and teardown shell blocks into worktree.posix.test.ts.

- worktree-bootstrap.test.ts: moved setup shell and terminal-backed service blocks into worktree-bootstrap.posix.test.ts.

- worktree-core.test.ts: moved the POSIX-only worktree-core suite into worktree-core.posix.test.ts and removed the empty original.

- provider-availability.test.ts and file-explorer/service.test.ts: moved POSIX PATH/symlink blocks into sibling suites.

* Fix Windows 8.3 short-name, EBUSY cleanup, and node-pty shell-path failures in server tests

- Normalize temp home directories in directory suggestion assertions to avoid Windows short-name mismatches.

- Use Windows-valid terminal shells/cwds in terminal fixtures and wait for terminal-manager PTYs before cleanup.

- Replace hardcoded POSIX paths in workspace-git/MCP/loop fixtures with platform-resolved paths or command files.

- Gate the two explicitly Linux-only workspace-git watcher tests.

* Replace hardcoded POSIX paths with portable Node path constructions in server tests

- checkout-git.test.ts and worktree-session.test.ts: compare Windows temp paths with realpathSync.native to avoid 8.3 short-name mismatches.

- directory-suggestions.test.ts: canonicalize result and expected paths with realpathSync.native.

- session.workspaces.test.ts: replace literal /tmp and /Users fixtures with path.resolve/path.join constructions.

- workspace-git-service.primitive.test.ts, loop-service.test.ts, and mcp-server.test.ts: use canonical repo/temp paths and shell-safe relative verify commands.

* Fix loop-service verify-check shell and workspace-git-service path-separator on Windows

- Run the loop-service verify script through the current Node executable with a relative script path.

- Normalize the workspace-git-service expected repo cwd to forward slashes for the listWorktrees assertion.

* Fix loop-service worker PTY spawn on Windows

- Canonicalize the loop-service temporary root and workspace with realpathSync.native before worker agents use the path as cwd.

* Gate loop-service real-worker-PTY test on Windows

- Skip the real worker PTY loop test on Windows after ConPTY path resolution still fails with node-pty error 267.\n- Keep the test running on POSIX so the loop behavior remains covered.

* Stub getMetricsSnapshot in test mocks to suppress async-leak uncaught exceptions on Windows

- Add a no-op AgentManager metrics snapshot to the WebSocket notification test server stub.

* Fix terminal-manager Windows-path test to avoid PTY spawn into nonexistent dir

- Use an existing temporary cwd for the createTerminal absolute-path validation assertion so node-pty does not spawn into a missing Windows directory.
2026-05-08 10:57:22 +08:00
Mohamed Boudra
39e461b872 perf(website): render hero text immediately 2026-05-07 23:38:32 +07:00
Mohamed Boudra
734e15e5a3 perf(website): cache GitHub release metadata in KV 2026-05-07 23:22:48 +07:00
Mohamed Boudra
90bf6571d6 Revert "perf(website): avoid blocking root render on GitHub"
This reverts commit 89a500cd3e.
2026-05-07 23:10:26 +07:00
Mohamed Boudra
90fe71bd54 chore: ignore generated TanStack files 2026-05-07 23:06:48 +07:00
Mohamed Boudra
89a500cd3e perf(website): avoid blocking root render on GitHub 2026-05-07 23:06:27 +07:00
Mohamed Boudra
a1ac402154 docs(website): add provider pages and refresh docs 2026-05-07 22:52:26 +07:00
Mohamed Boudra
f7eac82593 fix(website): smooth docs navigation and improve lighthouse 2026-05-07 22:42:39 +07:00
github-actions[bot]
4a9c2450b7 fix: update lockfile signatures and Nix hash 2026-05-07 15:19:24 +00:00
Mohamed Boudra
27f33be0e7 docs: update orchestration docs 2026-05-07 22:17:55 +07:00
Mohamed Boudra
4c11f7c40b test(cli): add Sonnet 4.6 1M to provider models expectation
CLI provider e2e test (15-provider.test.ts) pinned the claude catalog
size; PR #799 added claude-sonnet-4-6[1m] but did not update this list.
Sibling fix to 50405c3b.
2026-05-07 20:04:52 +07:00
Mohamed Boudra
50405c3b6a test(server): add Sonnet 4.6 1M to claude-agent listModels expectation
PR #799 added claude-sonnet-4-6[1m] to the model catalog and updated
claude-models.test.ts but missed the duplicate assertion in
claude-agent.test.ts.
2026-05-07 19:53:02 +07:00
github-actions[bot]
8406559150 fix: update lockfile signatures and Nix hash 2026-05-07 12:41:26 +00:00
Mohamed Boudra
f92a296ce0 chore(release): cut 0.1.70-beta.1 2026-05-07 19:40:10 +07:00
Mohamed Boudra
6786024333 docs(changelog): draft 0.1.70-beta.1 entry 2026-05-07 19:39:22 +07:00
Mohamed Boudra
56855dd6fd fix(server): keep requested cwd when creating an agent (#808)
Workspace lookup was rewriting the agent cwd to the parent workspace cwd. Pass the requested cwd through faithfully and still attach workspaceId for grouping.

Fixes #551
2026-05-07 20:28:57 +08:00
Mohamed Boudra
00a5b27586 Show opencode auth list in OpenCode diagnostic
Helps diagnose missing subscription models — surfaces which providers
the daemon-spawned opencode sees as authenticated.
2026-05-07 19:20:17 +07:00
Christoph Heer
73ed98c623 Add Sonnet 4.6 1M model to Claude model picker (#799)
Closes #334
2026-05-07 20:14:25 +08:00
Mohamed Boudra
927309e867 test(server): real-fs coverage for Linux walker gitignore skip (#806)
Adds a primitive test that initialises a real git repo, writes a
.gitignore covering an ignored subtree, and asserts the Linux working
tree walker registers watchers for kept directories but not for
gitignored ones. Exercises the real `git ls-files -o -i --directory
--exclude-standard` shell-out the new code path depends on.
2026-05-07 19:53:24 +08:00
Mohamed Boudra
2d2ee02ce3 Gate workspace open targets by daemon locality 2026-05-07 18:38:14 +07:00
Mohamed Boudra
4d4fbf7257 Drop dead test for removed Update installed branch
The "shows only the changelog action once the update is installed"
case in update-callout-source.test.tsx pinned the rendering path that
2d9c7747 deleted. The callout now early-returns for any status other
than available/installing/error, so the assertion that the container
contains "Update installed" can never be satisfied.
2026-05-07 18:35:44 +07:00
Mohamed Boudra
67c93dba49 Reorder settings sidebar: Projects after General 2026-05-07 18:27:11 +07:00
Mohamed Boudra
2d9c7747fb Fix misleading 'Update installed' callout flash
The install flow briefly flashed an "Update installed / Restart to use
the new version" callout between clicking Install & restart and the
actual app quit. The button implies action; the restart is already in
flight.

Run quitAndInstall inline (no 1.5s setTimeout) so the IPC sequences
download → install → daemon stop → app quit before returning. Drop the
"installed" rendering branch from the callout so the success path goes
straight from "Installing..." to the window unmounting.
2026-05-07 18:20:22 +07:00
xuzhe
4fa1db8d06 fix(server): stop Linux watcher event storms on busy working trees (#794)
On Linux fs.watch isn't recursive, so each working tree manually walks
the entire subtree and registers a per-directory watcher. Every inotify
event then re-scans the tree and rebuilds watchers. A working tree
containing a directory that's continuously written (e.g. test-runtime
data, build artefacts) drives the event loop to 100% CPU and the
daemon stops responding on its websocket.

Skip git-ignored directories when walking — load `git ls-files -o -i
--directory --exclude-standard` once per root (5 min cache) and skip
its entries. Cap the walked directory count at 5000 so a misconfigured
repo can't blow past the inotify budget. Add a 2s cooldown between
refresh passes so a flurry of events collapses into one re-scan
instead of a tight loop.

Co-authored-by: xuzhe.7766 <xuzhe.7766@bytedance.com>
2026-05-07 19:17:33 +08:00
Mohamed Boudra
92be6c0cba Replace skills auto-sync with explicit install/update/uninstall (#797)
* Replace skills auto-sync with explicit install/update/uninstall

Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.

A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.

Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.

* Rewrite skills install flow with disk-truth model

Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.

Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.

Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.

* Stop symlinking claude skills, write real files

If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.

* Make Uninstall button visible (outline, not ghost)
2026-05-07 18:41:13 +08:00
Ethan Greenfeld
4f0b264886 Fix ACP terminal shell command spawning (#793) 2026-05-07 16:24:30 +08:00
Mohamed Boudra
bad304c2d3 Detect GitHub issue/PR URL in composer search
Pasting a GitHub issue or pull request URL into the composer's
issue/PR picker now searches by the number from the URL instead
of treating the full URL as a keyword that returns no matches.
2026-05-07 15:23:45 +07:00
Mohamed Boudra
0785ee31f0 Match model description in combobox search
Search now matches across model label, id, provider, and description
with multi-token fuzzy matching, so "kimi zen" finds the OpenCode Zen
Kimi model.
2026-05-07 15:23:16 +07:00
209 changed files with 12189 additions and 7690 deletions

View File

@@ -66,7 +66,12 @@ jobs:
run: npm run typecheck
server-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
steps:
- uses: actions/checkout@v4
with:
@@ -83,6 +88,9 @@ jobs:
- name: Install dependencies
run: npm install
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
@@ -95,50 +103,6 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
server-tests-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Run Windows-critical server tests
working-directory: packages/server
run: >
npx vitest run
src/utils/executable.probe.test.ts
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/tree-kill.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
@@ -216,7 +180,7 @@ jobs:
run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
@@ -271,7 +235,7 @@ jobs:
run: npm install
- name: Install agent CLIs for provider tests
run: npm install -g @openai/codex@0.105.0 opencode-ai
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight

View File

@@ -36,6 +36,9 @@ jobs:
- name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight

1
.gitignore vendored
View File

@@ -66,6 +66,7 @@ CLAUDE.local.md
.paseo/
.wrangler/
**/.wrangler/
**/.tanstack/
# Local agent/tooling artifacts (do not commit)
PLAN.md

View File

@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
}

View File

@@ -1,5 +1,63 @@
# Changelog
## 0.1.70 - 2026-05-08
### Breaking
- **Claude agents now require `claude` on your PATH.** Install Claude Code globally (`npm install -g @anthropic-ai/claude-code`) before running a Claude agent — Paseo no longer ships a bundled fallback binary. Same posture as Codex and OpenCode, and shrinks the desktop install by ~210 MB per platform.
### Added
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
- Claude's Sonnet 4.6 1M context model is now selectable in the model picker.
- Detect GitHub issue and PR URLs pasted into the composer search.
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
- `paseo schedule update` to edit a schedule in place without recreating it.
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
### Improved
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
- Self-hosted relays can opt into `wss://` for TLS connections.
- Workspace open targets only show options reachable from the current daemon.
- Combobox search matches model descriptions, not just names.
- Codex image attachments render inline as path markdown.
- Subagent task notifications no longer clutter the parent agent's timeline.
- Voice mode: quieter thinking tone and small UI polish.
- Settings sidebar order: Projects now appears after General.
- Electron upgraded to 41.2.0 for the desktop app.
### Fixed
- **Claude agent: daemon no longer crashes mid-turn** when the underlying SDK fires a stray control message after the connection has been torn down.
- **Windows:** Terminals start reliably and shut down cleanly without leaving stuck processes behind.
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
- Daemon shutdown terminates all child processes cleanly using tree-kill.
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
- OpenCode now forwards provider retry errors instead of silently swallowing them.
- Codex import no longer reverts to the wrong default mode.
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
- Workspace navigation regression on web fixed.
- Duplicate workspace shell navigation eliminated.
- The 'Update installed' callout no longer flashes incorrectly.
- Browser pane reload focus and devtools handling.
- MCP terminal capture now includes scrollback.
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
## 0.1.69 - 2026-05-05
### Fixed

View File

@@ -14,7 +14,7 @@ There are two supported ways to ship from `main`:
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- Make sure local `npm run typecheck` passes on that commit.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

1697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.70",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.70",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -67,6 +67,22 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
});
it("matches across label, provider, and description with multi-token fuzzy search", () => {
const row = {
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
provider: "opencode",
providerLabel: "OpenCode",
modelId: "opencode-zen/kimi-k2.5",
modelLabel: "Kimi K2.5",
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
});
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");

View File

@@ -48,7 +48,10 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
.join(" ")
.toLowerCase();
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return tokens.every((token) => haystack.includes(token));
}

View File

@@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { theme } = useUnistyles();
@@ -25,16 +47,18 @@ export function IntegrationsSection() {
} = useCliInstall();
const {
status: skillsStatus,
isInstalling: isInstallingSkills,
isWorking: isSkillsWorking,
install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
refresh: refreshSkillsStatus,
} = useSkillsInstall();
} = useSkillsStatus();
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
refreshCliStatus();
refreshSkillsStatus();
void refreshSkillsStatus();
return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
);
@@ -45,9 +69,33 @@ export function IntegrationsSection() {
}, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
installSkills();
}, [installSkills, isInstallingSkills]);
if (isSkillsWorking) return;
void installSkills();
}, [installSkills, isSkillsWorking]);
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?",
message: UNINSTALL_MESSAGE,
confirmLabel: "Uninstall",
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
@@ -96,6 +144,8 @@ export function IntegrationsSection() {
return null;
}
const skillsState = skillsStatus?.state ?? null;
return (
<SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}>
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI
{skillsState === "drift"
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
</View>
</View>
</SettingsSection>
);
}
interface SkillsActionsProps {
state: SkillsStatus["state"] | null;
isWorking: boolean;
onInstall: () => void;
onUpdate: () => void;
onUninstall: () => void;
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { theme } = useUnistyles();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking ? "Working..." : "Update"}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
@@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));

View File

@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
function parseSkillsState(value: unknown): SkillsState {
switch (value) {
case "not-installed":
case "up-to-date":
case "drift":
return value;
default:
throw new Error(`Unexpected skills status state: ${String(value)}`);
}
}
function parseSkillOp(raw: unknown): SkillOp {
if (!isRecord(raw)) {
throw new Error("Unexpected skill op response.");
}
const name = toStringOrNull(raw.name);
if (!name) throw new Error("Skill op missing name.");
switch (raw.kind) {
case "add":
return { kind: "add", name };
case "update":
return { kind: "update", name };
case "delete":
return { kind: "delete", name };
default:
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
}
}
function parseSkillsStatus(raw: unknown): SkillsStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
return { state: parseSkillsState(raw.state), ops };
}
export async function getSkillsStatus(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
}
export async function installSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
}

View File

@@ -5,7 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCliInstall, useSkillsInstall } from "./use-install-status";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
error: vi.fn(),
@@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({
const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(),
getSkillsInstallStatus: vi.fn(),
installCli: vi.fn(),
getSkillsStatus: vi.fn(),
installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true),
}));
@@ -89,11 +91,9 @@ describe("useCliInstall", () => {
});
});
describe("useSkillsInstall", () => {
describe("useSkillsStatus", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
});
afterEach(() => {
@@ -101,34 +101,155 @@ describe("useSkillsInstall", () => {
vi.clearAllMocks();
});
it("loads skills install status", async () => {
const { result } = renderDesktopHook(() => useSkillsInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: true });
it("loads the current skills status", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "up-to-date",
ops: [],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
expect(result.current.isWorking).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it("toasts and exposes skills install errors", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsInstall());
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
expect(result.current.status?.state).toBe("not-installed");
});
await act(async () => {
await result.current.install();
});
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("update transitions drift to up-to-date", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "drift",
ops: [{ kind: "update", name: "paseo" }],
});
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("drift");
});
await act(async () => {
await result.current.update();
});
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("uninstall transitions up-to-date back to not-installed", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
desktopDaemon.uninstallSkills.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("up-to-date");
});
await act(async () => {
await result.current.uninstall();
});
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
});
});
it("isWorking flips while a mutation is in flight", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
let resolveInstall: ((value: unknown) => void) | null = null;
desktopDaemon.installSkills.mockImplementation(
() =>
new Promise((resolve) => {
resolveInstall = resolve;
}),
);
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
expect(result.current.isWorking).toBe(false);
let installPromise: Promise<void> = Promise.resolve();
act(() => {
result.current.install();
installPromise = result.current.install();
});
await waitFor(() => {
expect(result.current.isWorking).toBe(true);
});
await act(async () => {
resolveInstall?.({ state: "up-to-date", ops: [] });
await installPromise;
});
await waitFor(() => {
expect(result.current.isWorking).toBe(false);
});
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
it("toasts and exposes errors when install fails", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
await act(async () => {
await result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
});

View File

@@ -2,11 +2,14 @@ import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCliInstallStatus,
getSkillsInstallStatus,
getSkillsStatus,
installCli,
installSkills,
shouldUseDesktopDaemon,
type InstallStatus,
type SkillsStatus,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon";
import {
useDesktopIpcErrorReporter,
@@ -14,11 +17,7 @@ import {
} from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
"desktop",
"integrations",
"skills-install-status",
] as const;
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
interface DesktopInstallHookResult {
status: InstallStatus | null;
@@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult {
};
}
export function useSkillsInstall(): DesktopInstallHookResult {
export interface SkillsStatusHookResult {
status: SkillsStatus | null;
isLoading: boolean;
isWorking: boolean;
error: Error | null;
refresh: () => Promise<void>;
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsInstallStatus,
const statusQuery = useQuery<SkillsStatus, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY,
queryFn: getSkillsStatus,
enabled,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills install status.",
message: "Unable to check orchestration skills status.",
logLabel: "[Integrations] Failed to load skills status",
});
const installMutation = useMutation<InstallStatus, Error>({
const setStatus = useCallback(
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
},
onSuccess: setStatus,
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const refresh = useCallback(() => {
void refetch();
const updateMutation = useMutation<SkillsStatus, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
logLabel: "[Integrations] Failed to update skills",
});
},
onSuccess: setStatus,
});
const uninstallMutation = useMutation<SkillsStatus, Error>({
mutationFn: uninstallSkills,
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
logLabel: "[Integrations] Failed to uninstall skills",
});
},
onSuccess: setStatus,
});
const isWorking =
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const install = useCallback(async () => {
await installMutation.mutateAsync().catch(() => undefined);
}, [installMutation]);
const update = useCallback(async () => {
await updateMutation.mutateAsync().catch(() => undefined);
}, [updateMutation]);
const uninstall = useCallback(async () => {
await uninstallMutation.mutateAsync().catch(() => undefined);
}, [uninstallMutation]);
return {
status: installStatus ?? null,
status: status ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
isWorking,
error:
statusError ??
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
null,
refresh,
install,
update,
uninstall,
};
}

View File

@@ -184,21 +184,6 @@ describe("UpdateCalloutSource", () => {
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
});
it("shows only the changelog action once the update is installed", async () => {
updaterState.value = {
...updaterState.value,
status: "installed",
availableUpdate: null,
};
await renderHarness(root!);
expect(container?.textContent).toContain("Update installed");
expect(
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
).toContain("What's new");
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
});
it("disables the install action and shows Installing... while installing", async () => {
updaterState.value = {
...updaterState.value,

View File

@@ -13,25 +13,18 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
function resolveUpdateCalloutTitle(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
}): string {
if (args.isInstalled) return "Update installed";
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
if (args.isInstalling) return "Installing update";
if (args.isError) return "Update failed";
return "Update available";
}
function resolveUpdateCalloutDescription(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
errorMessage: string | null;
latestVersion: string | undefined;
}): ReactNode {
if (args.isInstalled) return "Restart to use the new version.";
if (args.isInstalling) return "Installing and restarting...";
if (args.isError) return args.errorMessage ?? "Something went wrong.";
if (args.latestVersion) {
@@ -43,7 +36,6 @@ function resolveUpdateCalloutDescription(args: {
}
function buildUpdateCalloutActions(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
openChangelog: () => void;
@@ -53,7 +45,7 @@ function buildUpdateCalloutActions(args: {
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
if (args.isError) {
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
} else if (!args.isInstalled) {
} else {
actions.push({
label: args.isInstalling ? "Installing..." : "Install & restart",
onPress: args.install,
@@ -107,29 +99,21 @@ export function UpdateCalloutSource() {
if (!isDesktopApp) {
return;
}
if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
) {
if (status !== "available" && status !== "installing" && status !== "error") {
return;
}
const isInstalled = status === "installed";
const isError = status === "error";
const isAvailable = !isInstalled && !isInstalling && !isError;
const isAvailable = !isInstalling && !isError;
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
const description = resolveUpdateCalloutDescription({
isInstalled,
isInstalling,
isError,
errorMessage,
latestVersion: availableUpdate?.latestVersion ?? undefined,
});
const actions = buildUpdateCalloutActions({
isInstalled,
isInstalling,
isError,
openChangelog,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -745,16 +745,19 @@ function SettingsSidebar({
) : null}
<View style={sidebarStyles.list}>
{items.map((item) => (
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
<Fragment key={item.id}>
<SidebarSectionButton
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>

View File

@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
@@ -27,6 +28,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -38,6 +40,7 @@ interface OpenTarget {
id: string;
label: string;
icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void;
}
@@ -82,14 +85,16 @@ export function WorkspaceOpenInEditorButton({
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldLoadTargets =
const shouldQueryWorkspace =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId],
enabled: shouldLoadTargets,
enabled: shouldLoadEditorTargets,
staleTime: 60_000,
retry: false,
queryFn: async () => {
@@ -112,7 +117,7 @@ export function WorkspaceOpenInEditorButton({
const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId,
cwd: shouldLoadTargets ? cwd : "",
cwd: shouldQueryWorkspace ? cwd : "",
});
const editorTargets = useMemo<OpenTarget[]>(
@@ -121,6 +126,7 @@ export function WorkspaceOpenInEditorButton({
id: editor.id,
label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => {
if (!client) {
throw new Error("Host is not connected");
@@ -149,13 +155,20 @@ export function WorkspaceOpenInEditorButton({
id: "github",
label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url),
};
}, [checkoutStatus]);
const targets = useMemo(
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
[editorTargets, githubTarget],
() =>
filterTargetsForDaemonLocation(
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
);
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -210,7 +223,7 @@ export function WorkspaceOpenInEditorButton({
}
}, [primaryOption, handleOpenTarget]);
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
return null;
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});

View File

@@ -0,0 +1,13 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.70",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.70",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -60,6 +60,11 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.6 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-sonnet-4-6[1m]",
model: "Sonnet 4.6 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.70",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -53,10 +53,22 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (!keepName) return;
if (keepName) {
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
// SDK ≥0.2.113 ships per-platform Claude Code binaries via optionalDependencies
// (~210 MB each). Paseo requires user-installed `claude` on PATH, matching how
// Codex/OpenCode are integrated, so drop every bundled copy.
const anthropicDir = path.join(nodeModules, "@anthropic-ai");
if (fs.existsSync(anthropicDir)) {
for (const entry of fs.readdirSync(anthropicDir)) {
if (entry.startsWith("claude-agent-sdk-")) {
rmSafe(path.join(anthropicDir, entry));
}
}
}
}
function pruneNodePty(nodeModules, platform, arch) {

View File

@@ -17,12 +17,13 @@ import {
downloadAndInstallUpdate,
type AppReleaseChannel,
} from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
import {
installCli,
getCliInstallStatus,
getSkillsStatus,
installSkills,
getSkillsInstallStatus,
} from "../integrations/integrations-manager.js";
uninstallSkills,
updateSkills,
} from "../integrations/skills/index.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
@@ -531,8 +532,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
get_skills_status: () => getSkillsStatus(),
install_skills: () => installSkills(),
get_skills_install_status: () => getSkillsInstallStatus(),
update_skills: () => updateSkills(),
uninstall_skills: () => uninstallSkills(),
};
}

View File

@@ -196,16 +196,9 @@ function buildCheckResult(input: {
};
}
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Use a short delay to allow the renderer to receive the response.
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
// ---------------------------------------------------------------------------
@@ -314,7 +307,7 @@ export async function downloadAndInstallUpdate(
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
scheduleQuitAndInstall(onBeforeQuit);
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
@@ -336,7 +329,7 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion;
downloading = false;
scheduleQuitAndInstall(onBeforeQuit);
await performQuitAndInstall(onBeforeQuit);
return {
installed: true,

View File

@@ -0,0 +1 @@
export { getCliInstallStatus, installCli } from "./install.js";

View File

@@ -0,0 +1,71 @@
import { promises as fs } from "node:fs";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./path.js";
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
import { ensurePathInShellRc } from "./shell-rc.js";
interface InstallStatus {
installed: boolean;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./cli-install-path";
import { resolveCliInstallSourcePath } from "./path";
describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => {

View File

@@ -0,0 +1,31 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
export function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}

View File

@@ -0,0 +1,97 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import log from "electron-log/main";
export interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
export function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}

View File

@@ -1,318 +0,0 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
import { syncSkills } from "./skill-sync.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
function getSkillSyncTargets(): {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
} {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
export async function installSkills(): Promise<InstallStatus> {
const targets = getSkillSyncTargets();
log.info("[integrations] installSkills", targets);
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill install failed", { skillName, error });
},
});
log.info("[integrations] installSkills done", result);
return getSkillsInstallStatus();
}
export async function autoUpdateSkillsIfInstalled(): Promise<{
ran: boolean;
changedFiles: number;
processedSkills: number;
}> {
const targets = getSkillSyncTargets();
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
try {
await fs.access(installedMarker);
} catch {
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
try {
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill auto-update failed", { skillName, error });
},
});
if (result.changedFiles > 0) {
log.info("[integrations] auto-updated paseo skills", result);
} else {
log.info("[integrations] paseo skills already up to date", result);
}
return { ran: true, ...result };
} catch (error) {
log.warn("[integrations] auto-update skills aborted", { error });
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
const accessResults = await Promise.all(
SKILL_NAMES.map((skillName) =>
fs
.access(path.join(claudeDir, skillName, "SKILL.md"))
.then(() => true)
.catch(() => false),
),
);
return { installed: accessResults.every(Boolean) };
}

View File

@@ -0,0 +1,10 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";

View File

@@ -0,0 +1,302 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
isPackaged: false,
},
}));
import {
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
interface Sandbox {
root: string;
targets: SkillTargets;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
await fs.mkdir(targets.sourceDir, { recursive: true });
return { root, targets };
}
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(rootDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(sourceDir, name), files);
}
async function writeOnDiskSkill(
agentsDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(agentsDir, name), files);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
}
async function pathExists(p: string): Promise<boolean> {
return fs
.access(p)
.then(() => true)
.catch(() => false);
}
describe("getSkillsStatus", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
});
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
});
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("installSkills / updateSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
const status = await installSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
for (const name of ["paseo", "paseo-loop"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
}
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const first = await installSkills(sandbox.targets);
const second = await installSkills(sandbox.targets);
expect(first).toEqual({ state: "up-to-date", ops: [] });
expect(second).toEqual({ state: "up-to-date", ops: [] });
});
});
describe("uninstallSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await installSkills(sandbox.targets);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const name of PASEO_SKILL_NAMES) {
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
}
for (const name of ["unslop", "tdd", "devbox"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(`user-${name}`);
}
});
it("is a no-op when nothing is installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
});
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
});

View File

@@ -0,0 +1,164 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export const PASEO_SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-chat",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
function resolveSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
const stat = await fs.stat(skillDir).catch(() => null);
if (!stat?.isDirectory()) return null;
const rels = await listFilesRecursive(skillDir);
const files: SkillFiles = new Map();
for (const rel of rels) {
const buf = await fs.readFile(path.join(skillDir, rel));
const sha = createHash("sha256").update(buf).digest("hex");
files.set(toPosix(rel), sha);
}
return files;
}
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
const out = new Map<string, SkillFiles>();
for (const name of PASEO_SKILL_NAMES) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
}
return true;
}
function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
if (disk.size === 0) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
.map((op) => op.name);
if (writes.length > 0) {
await syncSkills({
sourceDir: targets.sourceDir,
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
skillNames: writes,
});
}
for (const op of status.ops) {
if (op.kind !== "delete") continue;
await removeSkill(op.name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets);
}
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
await removeSkill(name, {
agentsDir: t.agentsDir,
claudeDir: t.claudeDir,
codexDir: t.codexDir,
});
}
return getSkillsStatus(t);
}

View File

@@ -0,0 +1,22 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
}
export function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
export function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
export function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}

View File

@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncSkills } from "./skill-sync";
import { removeSkill, syncSkills } from "./sync";
interface Sandbox {
root: string;
@@ -70,6 +70,11 @@ describe("syncSkills", () => {
"utf-8",
);
expect(agentsContent).toBe("new paseo content");
const claudeContent = await fs.readFile(
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(claudeContent).toBe("new paseo content");
const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8",
@@ -107,10 +112,10 @@ describe("syncSkills", () => {
),
).toBe("roles content");
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
const lstat = await fs.lstat(claudeLink);
expect(lstat.isSymbolicLink()).toBe(true);
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
"roles content",
);
});
@@ -254,3 +259,46 @@ describe("syncSkills", () => {
expect(result.processedSkills).toBe(0);
});
});
describe("removeSkill", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes the skill from all three targets when present", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await removeSkill("paseo", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
});
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
});
it("does not throw when targets are missing", async () => {
await expect(
removeSkill("does-not-exist", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
}),
).resolves.toBeUndefined();
});
});

View File

@@ -7,7 +7,6 @@ export interface SkillSyncOptions {
claudeDir: string;
codexDir: string;
skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void;
}
@@ -25,7 +24,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
return true;
}
async function listFilesRecursive(rootDir: string): Promise<string[]> {
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -53,38 +52,24 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
return changed;
}
async function ensureClaudeSkillLink(
skillName: string,
agentsDir: string,
claudeDir: string,
platform: NodeJS.Platform,
): Promise<number> {
await fs.mkdir(claudeDir, { recursive: true });
const target = path.join(agentsDir, skillName);
const linkPath = path.join(claudeDir, skillName);
export interface RemoveSkillTargets {
agentsDir: string;
claudeDir: string;
codexDir: string;
}
// Always rebuild the link rather than diffing it. fs.rm with force: true is
// a no-op when nothing is there, and matches existing install behavior.
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
// is preserved.
await fs.rm(linkPath, { recursive: true, force: true });
if (platform === "win32") {
try {
// Junctions don't require Developer Mode / admin like regular symlinks do.
await fs.symlink(target, linkPath, "junction");
return 0;
} catch {
return await syncDirectoryFiles(target, linkPath);
}
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
const paths = [
path.join(targets.agentsDir, skillName),
path.join(targets.claudeDir, skillName),
path.join(targets.codexDir, skillName),
];
for (const p of paths) {
await fs.rm(p, { recursive: true, force: true });
}
await fs.symlink(target, linkPath);
return 0;
}
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0;
let processedSkills = 0;
@@ -100,11 +85,9 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
path.join(options.agentsDir, skillName),
);
changedFiles += await ensureClaudeSkillLink(
skillName,
options.agentsDir,
options.claudeDir,
platform,
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.claudeDir, skillName),
);
changedFiles += await syncDirectoryFiles(
@@ -114,7 +97,8 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
processedSkills++;
} catch (error) {
options.onSkillError?.(skillName, error);
if (!options.onSkillError) throw error;
options.onSkillError(skillName, error);
}
}

View File

@@ -48,7 +48,6 @@ import {
createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
@@ -661,10 +660,6 @@ async function bootstrap(): Promise<void> {
registerNotificationHandlers();
registerOpenerHandlers();
void autoUpdateSkillsIfInstalled().catch((error) => {
log.warn("[integrations] auto-update skills failed", error);
});
await createMainWindow();
app.on("activate", async () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.69",
"version": "0.1.70",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.69",
"version": "0.1.70",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.69",
"version": "0.1.70",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.69",
"version": "0.1.70",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -57,9 +57,9 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.70",
"@getpaseo/relay": "0.1.70",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
import { isPlatform } from "../src/test-utils/platform.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -116,17 +117,21 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n");
});
test("logs worker signal exits even when the worker cannot log", async () => {
const result = await runSupervisorFixture({
workerSource: `
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
},
);
});

View File

@@ -13,15 +13,13 @@ import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
import pino from "pino";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY;
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const shouldRun = !process.env.CI && (hasOpenAICredentials || hasClaudeCredentials);
interface AgentMcpServerHandle {
url: string;
@@ -147,13 +145,20 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
};
}
(shouldRun ? describe : describe.skip)("getStructuredAgentResponse (e2e)", () => {
describe("getStructuredAgentResponse (e2e)", () => {
let manager: AgentManager;
let cwd: string;
let agentMcpServer: AgentMcpServerHandle;
let canRunCodex = false;
let canRunClaude = false;
const logger = pino({ level: "silent" });
beforeAll(async () => {
canRunCodex = !process.env.CI && hasOpenAICredentials;
canRunClaude = await isProviderAvailable("claude");
if (!canRunCodex && !canRunClaude) {
return;
}
agentMcpServer = await startAgentMcpServer(logger);
});
@@ -174,72 +179,70 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
await shutdownProviders(logger);
}, 60000);
test.runIf(hasOpenAICredentials)(
"returns schema-valid JSON from a real Codex agent",
async () => {
const schema = z.object({
title: z.string(),
count: z.number(),
});
test("returns schema-valid JSON from a real Codex agent", async (context) => {
if (!canRunCodex) {
context.skip();
}
const schema = z.object({
title: z.string(),
count: z.number(),
});
const result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Structured Response Test",
},
prompt: "Return JSON with a short title and count 2.",
schema,
maxRetries: 1,
});
const result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Structured Response Test",
},
prompt: "Return JSON with a short title and count 2.",
schema,
maxRetries: 1,
});
expect(result.title.length).toBeGreaterThan(0);
expect(typeof result.count).toBe("number");
},
180000,
);
expect(result.title.length).toBeGreaterThan(0);
expect(typeof result.count).toBe("number");
}, 180000);
test.runIf(hasClaudeCredentials)(
"returns schema-valid JSON from Claude Haiku",
async () => {
const schema = z.object({
message: z.string(),
});
test("returns schema-valid JSON from Claude Haiku", async (context) => {
if (!canRunClaude) {
context.skip();
}
const schema = z.object({
message: z.string(),
});
let result: { message: string } | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "claude",
model: "haiku",
thinkingOptionId: "on",
cwd,
title: "Claude Haiku Structured Test",
internal: true,
},
prompt:
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
schema,
maxRetries: 6,
});
lastError = null;
break;
} catch (error) {
lastError = error;
}
}
if (!result) {
throw lastError;
let result: { message: string } | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "claude",
model: "haiku",
thinkingOptionId: "on",
cwd,
title: "Claude Haiku Structured Test",
internal: true,
},
prompt:
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
schema,
maxRetries: 6,
});
lastError = null;
break;
} catch (error) {
lastError = error;
}
}
if (!result) {
throw lastError;
}
expect(result.message.trim().toLowerCase()).toBe("hello");
},
180000,
);
expect(result.message.trim().toLowerCase()).toBe("hello");
}, 180000);
});

View File

@@ -1,7 +1,8 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -24,6 +25,9 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
interface LooseSafeParseResult {
success: boolean;
data: unknown;
@@ -42,7 +46,7 @@ interface LooseStructuredContent {
interface RegisteredMcpTool {
inputSchema: LooseInputSchema;
callback: (input: unknown) => Promise<{
handler: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
@@ -383,7 +387,7 @@ describe("terminal MCP tools", () => {
});
const tool = registeredTool(server, "capture_terminal");
const response = await tool.callback({
const response = await tool.handler({
terminalId: "term-1",
scrollback: true,
stripAnsi: false,
@@ -507,7 +511,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithEmptyProvider.success).toBe(false);
await expect(
tool.callback({
tool.handler({
cwd: existingCwd,
mode: "default",
title: "Short title",
@@ -570,7 +574,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
cwd: "/path/that/does/not/exist",
title: "Short title",
provider: "codex/gpt-5.4",
@@ -583,7 +587,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -592,7 +596,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: " Fix auth bug ",
provider: "codex/gpt-5.4",
@@ -613,7 +617,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -622,7 +626,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: " Fix auth ",
provider: "codex/gpt-5.4",
@@ -642,7 +646,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -651,7 +655,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: "Config test",
mode: "auto",
@@ -685,14 +689,20 @@ describe("create_agent MCP tool", () => {
const startedAgentSetupIds: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree",
@@ -717,7 +727,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
@@ -757,14 +767,20 @@ describe("create_agent MCP tool", () => {
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree",
@@ -787,7 +803,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
@@ -798,7 +814,10 @@ describe("create_agent MCP tool", () => {
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(initialBranch).not.toBe("");
@@ -824,19 +843,28 @@ describe("create_agent MCP tool", () => {
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "existing-feature"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree",
@@ -859,7 +887,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: repoDir,
title: "Checkout agent",
provider: "codex/gpt-5.4",
@@ -871,7 +899,9 @@ describe("create_agent MCP tool", () => {
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -901,7 +931,7 @@ describe("create_agent MCP tool", () => {
},
workspace: {
workspaceId: "/tmp/worktrees/pr-123",
projectId: "/tmp/repo",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
displayName: "pr-123",
@@ -909,7 +939,7 @@ describe("create_agent MCP tool", () => {
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: "/tmp/repo",
repoRoot: REPO_CWD,
created: true,
...(options?.setupContinuation?.kind === "agent"
? {
@@ -948,8 +978,8 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: "/tmp/repo",
await tool.handler({
cwd: REPO_CWD,
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
@@ -985,14 +1015,20 @@ describe("create_agent MCP tool", () => {
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
};
@@ -1013,7 +1049,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
const response = await tool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
});
@@ -1031,19 +1067,27 @@ describe("create_agent MCP tool", () => {
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
);
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
@@ -1072,13 +1116,13 @@ describe("create_agent MCP tool", () => {
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.callback({
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
});
workspaceGitService.getSnapshot.mockClear();
await archiveTool.callback({
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
});
@@ -1126,9 +1170,9 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "list_worktrees");
const response = await tool.callback({ cwd: "/tmp/repo" });
const response = await tool.handler({ cwd: REPO_CWD });
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
@@ -1188,7 +1232,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: "subdir",
title: "Child",
provider: "codex/gpt-5.4",
@@ -1214,7 +1258,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123",
cwd: "/tmp/repo",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
@@ -1227,7 +1271,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
cwd: existingCwd,
title: "Injected config test",
mode: "auto",
@@ -1251,7 +1295,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
cwd: existingCwd,
title: "Bad mode",
provider: "opencode/gpt-5.4",
@@ -1288,7 +1332,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
title: "Child",
provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work",
@@ -1319,7 +1363,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.callback({
tool.handler({
title: "Child",
provider: "opencode/gpt-5.4",
initialPrompt: "Do work",
@@ -1354,7 +1398,7 @@ describe("create_agent MCP tool", () => {
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
await tool.handler({
title: "Child",
provider: "opencode/gpt-5.4",
mode: "build",
@@ -1384,7 +1428,7 @@ describe("create_schedule MCP tool", () => {
const tool = registeredTool(server, "create_schedule");
await expect(
tool.callback({
tool.handler({
prompt: "say hello",
every: "5m",
name: "Default schedule",
@@ -1404,12 +1448,12 @@ describe("create_schedule MCP tool", () => {
});
const tool = registeredTool(server, "create_schedule");
await tool.callback({
await tool.handler({
prompt: "say hello",
every: "5m",
provider: "codex",
});
await tool.callback({
await tool.handler({
prompt: "say hello again",
every: "10m",
provider: "codex/gpt-5.4",
@@ -1470,7 +1514,7 @@ describe("provider listing MCP tool", () => {
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.callback({});
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
@@ -1518,7 +1562,7 @@ describe("provider listing MCP tool", () => {
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.callback({});
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
@@ -1555,7 +1599,7 @@ describe("provider listing MCP tool", () => {
});
const tool = registeredTool(server, "list_providers");
await tool.callback({});
await tool.handler({});
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
expect(isAvailable).toHaveBeenCalledTimes(1);
@@ -1591,7 +1635,7 @@ describe("model listing MCP tool", () => {
});
const tool = registeredTool(server, "list_models");
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
expect(fetchModels).not.toHaveBeenCalled();
@@ -1615,7 +1659,7 @@ describe("speak MCP tool", () => {
const tool = registeredTool(server, "speak");
expect(tool).toBeDefined();
await tool.callback({ text: "Hello from voice agent." });
await tool.handler({ text: "Hello from voice agent." });
expect(speak).toHaveBeenCalledWith(
expect.objectContaining({
text: "Hello from voice agent.",
@@ -1635,7 +1679,7 @@ describe("speak MCP tool", () => {
logger,
});
const tool = registeredTool(server, "speak");
await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for caller agent",
);
});
@@ -1662,7 +1706,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({
id: "agent-compact",
provider: "codex",
cwd: "/tmp/repo",
cwd: REPO_CWD,
config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
labels: { role: "researcher" },
@@ -1671,7 +1715,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
const structured = z
.object({ agents: z.array(z.record(z.unknown())) })
.parse(response.structuredContent);
@@ -1687,7 +1731,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: "high",
effectiveThinkingOptionId: "high",
status: "idle",
cwd: "/tmp/repo",
cwd: REPO_CWD,
createdAt: expect.any(String),
updatedAt: expect.any(String),
lastUserMessageAt: null,
@@ -1725,7 +1769,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "archived-agent" });
const response = await tool.handler({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({
status: "closed",
@@ -1781,7 +1825,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "full-detail-agent" });
const response = await tool.handler({ agentId: "full-detail-agent" });
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
@@ -1857,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
});
const tool = registeredTool(server, "get_agent_status");
await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found",
);
});
@@ -1901,7 +1945,7 @@ describe("agent snapshot MCP serialization", () => {
callerAgentId: "caller-agent",
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(3);
@@ -1916,28 +1960,32 @@ describe("agent snapshot MCP serialization", () => {
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
id: "running-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "running",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "idle-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "idle",
updatedAt: new Date(recent),
}),
createManagedAgent({
id: "old-running-target",
cwd: "/tmp/target",
cwd: TARGET_CWD,
lifecycle: "running",
createdAt: new Date(old),
updatedAt: new Date(old),
}),
]);
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({
id: "recent-other-cwd",
cwd: resolvePath("/tmp/other"),
archivedAt: recent,
}),
]);
const server = await createAgentMcpServer({
@@ -1949,8 +1997,8 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({
cwd: "/tmp/target",
const response = await tool.handler({
cwd: TARGET_CWD,
includeArchived: true,
sinceHours: 48,
statuses: ["running", "closed"],
@@ -1990,7 +2038,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
const response = await tool.handler({ includeArchived: true });
const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(50);
@@ -2009,7 +2057,7 @@ describe("agent snapshot MCP serialization", () => {
spies.agentStorage.list.mockResolvedValue([
createStoredRecord({
id: "stored-archived-compact",
cwd: "/tmp/repo",
cwd: REPO_CWD,
updatedAt: now,
lastActivityAt: now,
archivedAt: now,
@@ -2033,7 +2081,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true });
const item = agentsOf(response)[0];
expect(item).toEqual({
@@ -2045,7 +2093,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: null,
effectiveThinkingOptionId: null,
status: "closed",
cwd: "/tmp/repo",
cwd: REPO_CWD,
createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: now,
lastUserMessageAt: null,
@@ -2106,7 +2154,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const response = await tool.handler({});
expect(agentsOf(response).map((agent) => agent.id)).toEqual([
"idle-attention-oldest",
@@ -2141,7 +2189,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
const response = await tool.handler({ includeArchived: true });
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) {
@@ -2181,7 +2229,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "archived-activity-agent" });
const response = await tool.handler({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual(
expect.objectContaining({
@@ -2219,7 +2267,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 });
const content = String(response.structuredContent.content);
expect(content).toContain("Hello world. How are you?");
@@ -2250,7 +2298,7 @@ describe("agent snapshot MCP serialization", () => {
},
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 });
const content = String(response.structuredContent.content);
expect(content).toContain("[User] u3");

View File

@@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({
isCommandAvailable: mockState.isCommandAvailable,
}));
vi.mock("./providers/claude-agent.js", () => ({
vi.mock("./providers/claude/agent.js", () => ({
ClaudeAgentClient: class ClaudeAgentClient {
readonly capabilities = {
supportsStreaming: true,

View File

@@ -21,7 +21,7 @@ import type {
ProviderProfileModel,
ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { ClaudeAgentClient } from "./providers/claude/agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";

View File

@@ -1,4 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
PermissionOption,
PromptResponse,
@@ -23,6 +25,7 @@ import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
interface ACPSessionInternals {
sessionId: string | null;
@@ -114,6 +117,14 @@ function createSessionWithConfig(
);
}
function createTerminalChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stdout = new EventEmitter() as ChildProcess["stdout"];
child.stderr = new EventEmitter() as ChildProcess["stderr"];
child.kill = vi.fn(() => true) as ChildProcess["kill"];
return child;
}
function selectConfigOption(
category: "mode" | "model" | "thought_level",
values: string[],
@@ -303,6 +314,78 @@ describe("createLoggedNdJsonStream", () => {
});
});
describe("ACPAgentSession terminal tools", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("runs single-string terminal commands through the platform shell", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const shell = spawnUtils.platformShell();
await session.createTerminal({
sessionId: "session-1",
command: "git -C /repo status --short",
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
shell.command,
[...shell.flag, "git -C /repo status --short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("preserves explicit terminal argv", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
await session.createTerminal({
sessionId: "session-1",
command: "git",
args: ["status", "--short"],
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
"git",
["status", "--short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
const child = createTerminalChildStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: "missing-command",
});
child.emit("error", new Error("spawn missing-command ENOENT"));
await expect(
session.waitForTerminalExit({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).rejects.toThrow("spawn missing-command ENOENT");
await expect(
session.terminalOutput({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).resolves.toMatchObject({
output: "spawn missing-command ENOENT\n",
truncated: false,
});
});
});
describe("mapACPUsage", () => {
test("maps ACP usage fields into Paseo usage", () => {
expect(

View File

@@ -92,7 +92,7 @@ import {
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes(
child: ChildProcess,
@@ -106,6 +106,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function resolveTerminalCommand(
command: string,
args?: string[],
): { command: string; args: string[] } {
if (args && args.length > 0) {
return { command, args };
}
if (!/\s/.test(command.trim())) {
return { command, args: [] };
}
const shell = platformShell();
return { command: shell.command, args: [...shell.flag, command] };
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -1464,7 +1480,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const child = spawnProcess(params.command, params.args ?? [], {
const terminalCommand = resolveTerminalCommand(params.command, params.args);
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
@@ -1479,6 +1496,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolveExit = resolve;
rejectExit = reject;
});
waitForExit.catch(() => undefined);
const entry: TerminalEntry = {
id: terminalId,
@@ -1498,9 +1516,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.once("error", (error) =>
rejectExit(error instanceof Error ? error : new Error(String(error))),
);
child.once("error", (error) => {
const spawnError = error instanceof Error ? error : new Error(String(error));
appendTerminalOutput(entry, `${spawnError.message}\n`);
rejectExit(spawnError);
});
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.exit = exit;

View File

@@ -1,476 +0,0 @@
import { describe, expect, test, beforeAll, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function compactText(value: string): string {
return value.replace(/\s+/g, "").toLowerCase();
}
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
async function nextStreamEvent(
stream: AsyncGenerator<AgentStreamEvent>,
timeoutMs: number,
label: string,
): Promise<IteratorResult<AgentStreamEvent>> {
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>,
options?: {
timeoutMs?: number;
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
},
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
if (next.done || !next.value) {
return events;
}
const event = next.value;
events.push(event);
await options?.onEvent?.(event);
if (isTerminalEvent(event)) {
return events;
}
}
}
async function collectUntil(
stream: AsyncGenerator<AgentStreamEvent>,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
const next = await nextStreamEvent(stream, timeoutMs, "matching stream event");
if (next.done || !next.value) {
throw new Error("Stream ended before the expected event arrived");
}
const event = next.value;
events.push(event);
if (predicate(event) || isTerminalEvent(event)) {
return events;
}
}
}
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
return new Promise((resolve, reject) => {
const events: AgentStreamEvent[] = [];
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
}, timeoutMs);
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!predicate(event)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(events);
});
});
}
function getAssistantText(events: AgentStreamEvent[]): string {
return events
.flatMap((event) => {
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
return [];
}
return [event.item.text];
})
.join("\n");
}
function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
return events.flatMap((event) => {
if (event.type !== "timeline" || event.item.type !== "tool_call") {
return [];
}
return [event.item];
});
}
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
return [...getToolCalls(events)]
.toReversed()
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function getInternalQuery(session: AgentSession): unknown {
return (session as AgentSession & { query?: unknown }).query ?? null;
}
async function createSession(params?: {
cwdPrefix?: string;
modeId?: string;
title?: string;
}): Promise<{ cwd: string; session: AgentSession }> {
const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-");
const session = await client.createSession({
provider: "claude",
cwd,
title: params?.title ?? "ClaudeAgentSession integration",
modeId: params?.modeId ?? "acceptEdits",
model: "haiku",
});
return { cwd, session };
}
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
}
describe("ClaudeAgentSession integration", () => {
let canRunClaudeIntegration = false;
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(await isCommandAvailable("claude")).toBe(true);
}
});
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
test("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("keeps bypassPermissions available after a thinking-option restart", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions",
});
try {
await handle.session.setMode("acceptEdits");
await handle.session.setThinkingOption("high");
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
const claudeQuery = query({
prompt: createEmptyPrompt(),
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
},
});
try {
const models = await claudeQuery.supportedModels();
expect(models.length).toBeGreaterThanOrEqual(3);
expect(models).toContainEqual(
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
expect.objectContaining({
value: "haiku",
displayName: "Haiku",
description: expect.stringContaining("Haiku 4.5"),
}),
);
expect(
models.some(
(model) =>
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
),
).toBe(true);
} finally {
await claudeQuery.return?.();
}
}, 60_000);
test.runIf(canRunClaudeIntegration)(
"runs a real Bash tool call and completes it",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"interrupts a running Bash turn and continues on the same query",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"creates an autonomous live turn when a background task completes",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"surfaces permission requests and resumes after approval",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
},
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
});

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
import { createDaemonTestContext, type DaemonTestContext } from "../../../test-utils/index.js";
// Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
describe("claude agent commands E2E", () => {

View File

@@ -1,15 +1,15 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("claude");
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
@@ -19,8 +19,6 @@ describe("claude agent commands contract (real)", () => {
});
test("lists slash commands with the expected contract", async () => {
expect(await isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});

View File

@@ -1,9 +1,10 @@
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../agent-sdk-types.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -36,7 +37,7 @@ describe("Claude SDK env", () => {
PASEO_TEST_FLAG: "launch-value",
},
};
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedEnv = options.env;
return createQueryMock([
{
@@ -66,6 +67,7 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession(
{
@@ -93,7 +95,7 @@ describe("Claude SDK env", () => {
PASEO_TEST_FLAG: "resume-launch-value",
},
};
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedEnv = options.env;
return createQueryMock([
{
@@ -123,6 +125,7 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.resumeSession(
{

View File

@@ -6,9 +6,7 @@
*
* All tests use REAL Claude SDK sessions no mocks.
*
* CREDENTIALS: These tests require a running `claude` CLI and either
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
* They are skipped automatically when credentials are unavailable.
* These tests run when the shared Claude provider availability gate passes.
*/
import { beforeAll, beforeEach, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
@@ -17,8 +15,8 @@ import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -26,8 +24,6 @@ import { ClaudeAgentClient } from "../claude-agent.js";
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
@@ -74,7 +70,14 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
try {
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
throw error;
}
}
}
async function startTurnAndCollectEvents(
@@ -196,7 +199,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
let canRun = false;
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { extractUserMessageText } from "./claude-agent.js";
import { extractUserMessageText } from "./agent.js";
describe("extractUserMessageText", () => {
test("returns trimmed string content", () => {

View File

@@ -0,0 +1,471 @@
import { describe, expect, test, beforeAll, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type {
AgentSession,
AgentStreamEvent,
ToolCallTimelineItem,
} from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { findExecutable } from "../../../../utils/executable.js";
import { withTimeout } from "../../../../utils/promise-timeout.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeQuery } from "./query.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function compactText(value: string): string {
return value.replace(/\s+/g, "").toLowerCase();
}
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
async function nextStreamEvent(
stream: AsyncGenerator<AgentStreamEvent>,
timeoutMs: number,
label: string,
): Promise<IteratorResult<AgentStreamEvent>> {
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>,
options?: {
timeoutMs?: number;
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
},
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
const next = await nextStreamEvent(stream, options?.timeoutMs ?? 45_000, "stream event");
if (next.done || !next.value) {
return events;
}
const event = next.value;
events.push(event);
await options?.onEvent?.(event);
if (isTerminalEvent(event)) {
return events;
}
}
}
async function collectUntil(
stream: AsyncGenerator<AgentStreamEvent>,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
while (true) {
const next = await nextStreamEvent(stream, timeoutMs, "matching stream event");
if (next.done || !next.value) {
throw new Error("Stream ended before the expected event arrived");
}
const event = next.value;
events.push(event);
if (predicate(event) || isTerminalEvent(event)) {
return events;
}
}
}
function collectSubscribedUntil(
session: AgentSession,
predicate: (event: AgentStreamEvent) => boolean,
timeoutMs = 45_000,
): Promise<AgentStreamEvent[]> {
return new Promise((resolve, reject) => {
const events: AgentStreamEvent[] = [];
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out after ${timeoutMs}ms waiting for subscribed event`));
}, timeoutMs);
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (!predicate(event)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(events);
});
});
}
function getAssistantText(events: AgentStreamEvent[]): string {
return events
.flatMap((event) => {
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
return [];
}
return [event.item.text];
})
.join("\n");
}
function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
return events.flatMap((event) => {
if (event.type !== "timeline" || event.item.type !== "tool_call") {
return [];
}
return [event.item];
});
}
function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelineItem | undefined {
return [...getToolCalls(events)]
.toReversed()
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function getInternalQuery(session: AgentSession): unknown {
return (session as AgentSession & { query?: unknown }).query ?? null;
}
async function createSession(params?: {
cwdPrefix?: string;
modeId?: string;
title?: string;
}): Promise<{ cwd: string; session: AgentSession }> {
const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-");
const session = await client.createSession({
provider: "claude",
cwd,
title: params?.title ?? "ClaudeAgentSession integration",
modeId: params?.modeId ?? "acceptEdits",
model: "haiku",
});
return { cwd, session };
}
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined);
try {
rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
throw error;
}
}
}
describe("ClaudeAgentSession integration", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("keeps bypassPermissions available after a thinking-option restart", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions",
});
try {
await handle.session.setMode("acceptEdits");
await handle.session.setThinkingOption("high");
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
const claudeBinary = await findExecutable("claude");
if (!claudeBinary) throw new Error("claude binary required for this integration test");
const query = claudeQuery({
prompt: createEmptyPrompt(),
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
pathToClaudeCodeExecutable: claudeBinary,
},
});
try {
const models = await query.supportedModels();
expect(models.length).toBeGreaterThanOrEqual(3);
expect(models).toContainEqual(
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
expect.objectContaining({
value: "haiku",
displayName: "Haiku",
description: expect.stringContaining("Haiku 4.5"),
}),
);
expect(
models.some(
(model) =>
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
),
).toBe(true);
} finally {
await query.return?.();
}
}, 60_000);
test("runs a real Bash tool call and completes it", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("interrupts a running Bash turn and continues on the same query", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
test("creates an autonomous live turn when a background task completes", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 90_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
90_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 180_000);
test("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
},
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
});

View File

@@ -1,9 +1,9 @@
import { afterEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
interface QueryMock {
next: ReturnType<typeof vi.fn>;
@@ -41,13 +41,7 @@ type PromptHandler = (input: {
query: ScriptedQuery;
}) => void | Promise<void>;
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const queryFactory = vi.fn();
function createAsyncQueue<T>(): AsyncQueue<T> {
const items: T[] = [];
@@ -245,14 +239,14 @@ async function waitFor(
}
afterEach(() => {
sdkMocks.query.mockReset();
queryFactory.mockReset();
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-keep-query-session",
@@ -261,7 +255,11 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -274,7 +272,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
await session.interrupt();
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled();
const firstTurnEvents = await collectUntilTerminal(firstTurn);
@@ -291,7 +289,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "interrupt-reuse-query-session",
@@ -311,7 +309,11 @@ test("reuses the existing query after interrupt before starting the next prompt"
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -326,7 +328,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
"first prompt",
"second prompt",
@@ -342,7 +344,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "claude-original-session",
@@ -361,7 +363,11 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -390,7 +396,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
const prompts: PromptRecord[] = [];
let throwAbortOnNext = false;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = {
next: vi.fn(async () => {
if (throwAbortOnNext) {
@@ -455,7 +461,11 @@ test("recovers when the query pump sees a single interrupt abort before the next
return scriptedQuery;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -468,7 +478,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
@@ -480,7 +490,7 @@ test("stale abort result after replacement start does not poison the new foregro
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "interrupt-stale-result-session",
@@ -488,7 +498,11 @@ test("stale abort result after replacement start does not poison the new foregro
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -545,7 +559,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-live-session",
@@ -564,7 +578,11 @@ test("creates an autonomous live turn when assistant output arrives without a fo
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -606,7 +624,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({
prompt,
sessionId: "autonomous-handoff-session",
@@ -634,7 +652,11 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
return queryRef;
});
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -677,7 +699,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
(event) => event?.type === "turn_canceled",
),
).toBe(false);
expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
"seed prompt",
"foreground prompt",

View File

@@ -1,13 +1,10 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
@@ -32,7 +29,7 @@ describe("Claude max effort availability (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {

View File

@@ -1,11 +1,11 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { Logger } from "pino";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { asInternals } from "../../../test-utils/class-mocks.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
interface QueryMock {
next: ReturnType<typeof vi.fn>;
@@ -66,6 +66,7 @@ async function createSession() {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
return client.createSession({
provider: "claude",
@@ -77,6 +78,7 @@ function createSessionWithLogger(logger: Logger) {
const client = new ClaudeAgentClient({
logger,
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
return client.createSession({
provider: "claude",
@@ -223,6 +225,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
},
},
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
@@ -657,7 +660,7 @@ test("Grep tool_result string content flows to a search detail with content", as
grepEntry,
);
const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js");
const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js");
const item = mapClaudeCompletedToolCall({
callId: "tool-grep-1",
name: "Grep",
@@ -797,6 +800,7 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
const client = new ClaudeAgentClient({
logger: loggerSpy.logger,
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",

View File

@@ -1,16 +1,16 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import {
query,
type Options,
type Query,
type SpawnOptions as ClaudeSpawnOptions,
import type {
Options,
Query,
SpawnOptions as ClaudeSpawnOptions,
} from "@anthropic-ai/claude-agent-sdk";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import * as spawnUtils from "../../../utils/spawn.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as spawnUtils from "../../../../utils/spawn.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -47,7 +47,7 @@ describe("Claude spawn override", () => {
test("bypasses the shell when spawning Claude Code", async () => {
let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock([
{
@@ -77,6 +77,7 @@ describe("Claude spawn override", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",

View File

@@ -1,19 +1,13 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent-manager.js";
import { projectTimelineRows } from "../timeline-projection.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentTimelineRow } from "../../agent-manager.js";
import { projectTimelineRows } from "../../timeline-projection.js";
import { ClaudeAgentClient } from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const queryFactory = vi.fn();
interface QueryMock {
next: ReturnType<typeof vi.fn>;
@@ -150,7 +144,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
beforeEach(() => {
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
sdkMocks.query.mockImplementation(() =>
queryFactory.mockImplementation(() =>
buildQueryMock([
{
type: "system",
@@ -247,11 +241,15 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
});
afterEach(() => {
sdkMocks.query.mockReset();
queryFactory.mockReset();
});
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({ logger }).createSession({
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({
provider: "claude",
cwd: process.cwd(),
});
@@ -298,9 +296,13 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({ logger }).createSession({
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({
provider: "claude",
cwd: process.cwd(),
});

View File

@@ -1,19 +1,24 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as executableUtils from "../../../../utils/executable.js";
import {
ClaudeAgentClient,
convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionUpdatedInput,
} from "./claude-agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
} from "./agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertUsage(message: SDKMessage): AgentUsage | undefined;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test";
@@ -346,7 +351,7 @@ describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger();
test("returns hardcoded claude models", async () => {
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
expect(models.map((m) => m.id)).toEqual([
@@ -354,6 +359,7 @@ describe("ClaudeAgentClient.listModels", () => {
"claude-opus-4-7",
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]);
@@ -368,6 +374,59 @@ describe("ClaudeAgentClient.listModels", () => {
});
});
describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("uses the replace-command override binary when claude is not on PATH", async () => {
const customClaudePath = "/path/to/custom-claude";
vi.spyOn(executableUtils, "findExecutable").mockImplementation(async (name: string) => {
if (name === "claude") {
return null;
}
if (name === customClaudePath) {
return customClaudePath;
}
return null;
});
const queryReturn = vi.fn();
queryReturn.mockResolvedValue(undefined);
const queryFactory = vi.fn(() => ({
close: vi.fn(),
return: queryReturn,
}));
const client = new ClaudeAgentClient({
logger,
queryFactory,
runtimeSettings: {
command: {
mode: "replace",
argv: [customClaudePath],
},
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await expect(
(
session as unknown as {
ensureQuery(): Promise<unknown>;
}
).ensureQuery(),
).resolves.toBeDefined();
expect(queryFactory.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable).toBe(
customClaudePath,
);
await session.close();
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => {
expect(
@@ -429,7 +488,10 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
});
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -504,7 +566,7 @@ describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
async function createSessionForTest(): Promise<TestClaudeSession> {
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -604,6 +666,7 @@ describe("ClaudeAgentSession context window usage", () => {
const nonPersistedClient = new ClaudeAgentClient({
logger,
queryFactory: nonPersistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const nonPersistedSession = await nonPersistedClient.createSession(
{
@@ -622,6 +685,7 @@ describe("ClaudeAgentSession context window usage", () => {
const persistedClient = new ClaudeAgentClient({
logger,
queryFactory: persistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const persistedSession = await persistedClient.createSession(
{
@@ -979,7 +1043,11 @@ describe("ClaudeAgentSession context window usage", () => {
},
],
]);
const client = new ClaudeAgentClient({ logger, queryFactory });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),

View File

@@ -1,20 +1,16 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
query,
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type Options,
type PermissionMode,
type PermissionResult,
type PermissionUpdate,
type Query,
type SpawnOptions,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
@@ -28,22 +24,23 @@ import {
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./claude/tool-call-mapper.js";
} from "./tool-call-mapper.js";
import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
} from "./task-notification-tool-call.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
} from "../diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import type {
AgentPermissionAction,
@@ -73,20 +70,19 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
} from "../../agent-sdk-types.js";
import {
createProviderEnv,
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { buildSelfNodeCommand } from "../../paseo-env.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
} from "../../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
import { withTimeout } from "../../../../utils/promise-timeout.js";
import { execCommand } from "../../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = ["user", "project"];
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
@@ -155,10 +151,6 @@ function isUnknownArray(value: unknown): value is readonly unknown[] {
return Array.isArray(value);
}
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
}
function isImageMimeType(
value: string,
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
@@ -239,7 +231,6 @@ interface SlashCommandInvocation {
rawInput: string;
}
// Orchestrator instructions moved to shared module.
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
export interface ClaudeContentChunk {
@@ -247,13 +238,12 @@ export interface ClaudeContentChunk {
[key: string]: unknown;
}
type ClaudeOptions = Options;
interface ClaudeAgentClientOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: typeof query;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
}
interface ClaudeAgentSessionOptions {
@@ -263,7 +253,8 @@ interface ClaudeAgentSessionOptions {
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
queryFactory?: typeof query;
queryFactory?: ClaudeQueryFactory;
resolveBinary: () => Promise<string>;
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
@@ -303,86 +294,6 @@ function extractSessionIdRaw(msg: {
return "";
}
function resolveClaudeSpawnCommand(
spawnOptions: SpawnOptions,
runtimeSettings?: ProviderRuntimeSettings,
): { command: string; args: string[] } {
const commandConfig = runtimeSettings?.command;
if (!commandConfig || commandConfig.mode === "default") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args],
};
}
if (commandConfig.mode === "append") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args, ...(commandConfig.args ?? [])],
};
}
return {
command: commandConfig.argv[0],
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
};
}
function applyRuntimeSettingsToClaudeOptions(
options: ClaudeOptions,
runtimeSettings?: ProviderRuntimeSettings,
launchEnv?: Record<string, string>,
): ClaudeOptions {
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
// process.execPath — the actual node binary running the daemon. This avoids
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
const providerEnvSpec = createProviderEnvSpec({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
const providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
: null;
const command = selfNodeCommand?.command ?? resolved.command;
const args = selfNodeCommand?.args ?? resolved.args;
const child = spawnProcess(command, args, {
cwd: spawnOptions.cwd,
...(selfNodeCommand
? { env: selfNodeCommand.env, envMode: "internal" as const }
: providerEnvSpec),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
// containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
// The command is always a resolved binary path, so shell routing is unnecessary.
shell: false,
});
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
options.stderr?.(chunk.toString());
});
}
if (!isChildProcessWithStreams(child)) {
throw new Error("Claude process was spawned without stdio streams");
}
return child;
},
};
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return (
value === "low" ||
@@ -1250,13 +1161,15 @@ export class ClaudeAgentClient implements AgentClient {
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory: typeof query;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory ?? query;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
}
async createSession(
@@ -1272,6 +1185,7 @@ export class ClaudeAgentClient implements AgentClient {
persistSession: options?.persistSession,
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
});
}
@@ -1298,6 +1212,7 @@ export class ClaudeAgentClient implements AgentClient {
launchEnv: launchContext?.env,
logger: this.logger,
queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
});
}
@@ -1329,9 +1244,7 @@ export class ClaudeAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
// via process.execPath. No external `claude` binary is required.
return true;
return await isCommandAvailable("claude");
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -1383,6 +1296,24 @@ export class ClaudeAgentClient implements AgentClient {
}
}
async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> {
const command = runtimeSettings?.command;
if (command?.mode === "replace") {
const foundOverride = await findExecutable(command.argv[0]);
if (foundOverride) {
return foundOverride;
}
}
const found = await findExecutable("claude");
if (found) {
return found;
}
throw new Error(
"Claude binary not found. Install Claude Code (https://github.com/anthropics/claude-code) and ensure it is available in your shell PATH.",
);
}
async function resolveClaudeVersion(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
@@ -1550,7 +1481,8 @@ class ClaudeAgentSession implements AgentSession {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
private readonly logger: Logger;
private readonly queryFactory: typeof query;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private input: AsyncMessageInput<SDKUserMessage> | null = null;
private claudeSessionId: string | null;
@@ -1598,7 +1530,8 @@ class ClaudeAgentSession implements AgentSession {
this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession;
this.logger = options.logger;
this.queryFactory = options.queryFactory ?? query;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary;
const handle = options.handle;
if (handle) {
@@ -2218,7 +2151,14 @@ class ClaudeAgentSession implements AgentSession {
const options = await this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.query = this.queryFactory({ prompt: input.iterable, options });
this.query = claudeQuery(
{ prompt: input.iterable, options },
{
runtimeSettings: this.runtimeSettings,
launchEnv: this.launchEnv,
queryFactory: this.queryFactory,
},
);
// Do not kick off background control-plane queries here. Methods like
// supportedCommands()/setPermissionMode() may execute immediately after
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
@@ -2256,11 +2196,6 @@ class ClaudeAgentSession implements AgentSession {
? this.config.thinkingOptionId
: undefined;
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
if (thinkingOptionId === "xhigh") {
// "xhigh" is accepted by Claude Opus 4.7 but not yet in the SDK type definitions
// @ts-expect-error -- SDK 0.2.71 effort type doesn't include "xhigh" yet
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
}
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
}
return { thinking: undefined, effort: undefined };
@@ -2290,7 +2225,7 @@ class ClaudeAgentSession implements AgentSession {
],
});
const claudeBinary = await findExecutable("claude");
const claudeBinary = await this.resolveBinary();
this.logger.debug(
{
claudeBinary,
@@ -2311,7 +2246,7 @@ class ClaudeAgentSession implements AgentSession {
allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
pathToClaudeCodeExecutable: claudeBinary,
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions for agents.
systemPrompt: {
@@ -2353,11 +2288,7 @@ class ClaudeAgentSession implements AgentSession {
...this.runtimeSettings.disallowedTools,
];
}
return this.applyRuntimeSettings(base);
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
return base;
}
private normalizeMcpServers(

View File

@@ -3,19 +3,13 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const queryFactory = vi.fn();
let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
@@ -99,9 +93,9 @@ describe("ClaudeAgentSession history replay regression", () => {
let previousClaudeConfigDir: string | undefined;
beforeEach(() => {
sdkMocks.query.mockImplementation(() => {
queryFactory.mockImplementation(() => {
const mock = buildSdkQueryMock();
sdkMocks.lastQuery = mock;
lastQuery = mock;
return mock;
});
@@ -145,8 +139,8 @@ describe("ClaudeAgentSession history replay regression", () => {
});
afterEach(() => {
sdkMocks.query.mockReset();
sdkMocks.lastQuery = null;
queryFactory.mockReset();
lastQuery = null;
if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
@@ -157,7 +151,11 @@ describe("ClaudeAgentSession history replay regression", () => {
test("does not replay persisted history during the first live stream turn", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
@@ -194,7 +192,11 @@ describe("ClaudeAgentSession history replay regression", () => {
test("still exposes persisted history through streamHistory", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
@@ -223,7 +225,11 @@ describe("ClaudeAgentSession history replay regression", () => {
test("listCommands includes rewind command", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
@@ -245,7 +251,11 @@ describe("ClaudeAgentSession history replay regression", () => {
test("slash /rewind uses latest user message id from persisted history", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
@@ -276,9 +286,9 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
expect(sdkMocks.lastQuery).toBeTruthy();
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
expect(lastQuery).toBeTruthy();
expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
dryRun: false,
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
describe("getClaudeModels", () => {
it("returns all claude models", () => {
@@ -10,6 +10,7 @@ describe("getClaudeModels", () => {
"claude-opus-4-7",
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]);

View File

@@ -45,6 +45,13 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
isDefault: true,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-4-6",

View File

@@ -0,0 +1,120 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
import {
createProviderEnv,
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { buildSelfNodeCommand } from "../../../paseo-env.js";
import { spawnProcess } from "../../../../utils/spawn.js";
// Keep the raw SDK query import in this module only. Claude process launch behavior
// must stay shared between production and tests so Windows .cmd/.bat handling cannot
// diverge from the daemon path.
export type ClaudeOptions = Options;
export type ClaudeQueryInput = Parameters<typeof query>[0] & { options: ClaudeOptions };
export type ClaudeQueryFactory = (input: ClaudeQueryInput) => Query;
export interface ClaudeQueryContext {
runtimeSettings?: ProviderRuntimeSettings;
launchEnv?: Record<string, string>;
queryFactory?: ClaudeQueryFactory;
}
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
}
function resolveClaudeSpawnCommand(
spawnOptions: SpawnOptions,
runtimeSettings?: ProviderRuntimeSettings,
): { command: string; args: string[] } {
const commandConfig = runtimeSettings?.command;
if (!commandConfig || commandConfig.mode === "default") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args],
};
}
if (commandConfig.mode === "append") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args, ...(commandConfig.args ?? [])],
};
}
return {
command: commandConfig.argv[0],
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
};
}
function applyRuntimeSettingsToClaudeOptions(
options: ClaudeOptions,
runtimeSettings?: ProviderRuntimeSettings,
launchEnv?: Record<string, string>,
): ClaudeOptions {
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings);
// When the SDK passes a default JS runtime ("node"/"bun"), replace it with
// process.execPath — the actual node binary running the daemon. This avoids
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
const providerEnvSpec = createProviderEnvSpec({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
const providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
: null;
const command = selfNodeCommand?.command ?? resolved.command;
const args = selfNodeCommand?.args ?? resolved.args;
const child = spawnProcess(command, args, {
cwd: spawnOptions.cwd,
...(selfNodeCommand
? { env: selfNodeCommand.env, envMode: "internal" as const }
: providerEnvSpec),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
// containing double quotes, which cmd.exe mangles (strips quotes, breaks parsing).
// The command is always a resolved binary path, so shell routing is unnecessary.
shell: false,
});
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
options.stderr?.(chunk.toString());
});
}
if (!isChildProcessWithStreams(child)) {
throw new Error("Claude process was spawned without stdio streams");
}
return child;
},
};
}
export function claudeQuery(input: ClaudeQueryInput, context: ClaudeQueryContext = {}): Query {
const launchQuery = context.queryFactory ?? query;
return launchQuery({
...input,
options: applyRuntimeSettingsToClaudeOptions(
input.options,
context.runtimeSettings,
context.launchEnv,
),
});
}

View File

@@ -1,12 +1,14 @@
/**
* Direct SDK behavior tests - uses same setup as claude-agent.ts
* Direct SDK behavior tests - uses same setup as the Claude provider
*/
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { findExecutable } from "../../../../utils/executable.js";
import { claudeQuery } from "./query.js";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
@@ -53,6 +55,17 @@ function tmpCwd(): string {
}
}
function rmCwd(cwd: string): void {
try {
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") {
throw error;
}
}
}
function extractTextFromEvents(events: SDKMessage[]): string {
let responseText = "";
for (const event of events) {
@@ -72,21 +85,15 @@ function extractTextFromEvents(events: SDKMessage[]): string {
return responseText;
}
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => {
let canRunClaudeIntegration = false;
let canRun = false;
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(await isCommandAvailable("claude")).toBe(true);
}
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRunClaudeIntegration) {
if (!canRun) {
context.skip();
}
});
@@ -96,8 +103,8 @@ describe("Claude SDK direct behavior", () => {
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
// Use same options as claude-agent.ts
const q = query({
// Use same options as the Claude provider
const q = claudeQuery({
prompt: input,
options: {
cwd,
@@ -160,7 +167,7 @@ describe("Claude SDK direct behavior", () => {
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
rmCwd(cwd);
}
}, 120000);
});

View File

@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
expect(event.item.text).not.toContain("data:image");
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(event.item.text);
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
});

View File

@@ -49,7 +49,7 @@ import {
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
@@ -1228,6 +1228,20 @@ export class OpenCodeAgentClient implements AgentClient {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
}
let authValue = "Not checked";
if (resolvedBinary) {
try {
const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], {
...createProviderEnvSpec(),
timeout: 5_000,
});
const text = (stdout.trim() || stderr.trim()).trim();
authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)";
} catch (error) {
authValue = `Error - ${toDiagnosticErrorMessage(error)}`;
}
}
if (available) {
try {
const models = await this.listModels({ cwd: homedir(), force: false });
@@ -1263,6 +1277,7 @@ export class OpenCodeAgentClient implements AgentClient {
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Server", value: serverStatus },
{ label: "Auth", value: authValue },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),

View File

@@ -0,0 +1,71 @@
// POSIX-only: POSIX PATH executable probing fixtures
/* eslint-disable max-nested-callbacks */
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { isPlatform } from "../../../test-utils/platform.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
const originalEnv = {
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function isolatePathTo(dir: string): void {
process.env.PATH = dir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
}
function writeProviderShim(dir: string, command: string): string {
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
const content =
process.platform === "win32"
? `@echo off\r\necho ${command} 1.0\r\n`
: `#!/bin/sh\necho ${command} 1.0\n`;
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe.skipIf(isPlatform("win32"))("provider-availability POSIX-only", () => {
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
@@ -8,7 +8,7 @@ import type { AgentProvider } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { ClaudeAgentClient } from "./claude/agent.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
@@ -31,19 +31,6 @@ function isolatePathTo(dir: string): void {
}
}
function writeProviderShim(dir: string, command: string): string {
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
const content =
process.platform === "win32"
? `@echo off\r\necho ${command} 1.0\r\n`
: `#!/bin/sh\necho ${command} 1.0\n`;
writeFileSync(filePath, content);
if (process.platform !== "win32") {
chmodSync(filePath, 0o755);
}
return filePath;
}
afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
@@ -61,12 +48,12 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
test("Claude reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-claude-");
isolatePathTo(binDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.isAvailable()).resolves.toBe(true);
await expect(client.isAvailable()).resolves.toBe(false);
});
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
@@ -77,24 +64,6 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir);

View File

@@ -8,6 +8,7 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -152,52 +153,56 @@ describe("paseo daemon bootstrap", () => {
});
});
test("generates a relay pairing offer for unix socket listeners", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const logger = pino({ level: "silent" });
// POSIX-only: Unix socket listen paths are invalid Windows listen targets.
test.skipIf(isPlatform("win32"))(
"generates a relay pairing offer for unix socket listeners",
async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock");
await mkdir(path.dirname(socketPath), { recursive: true });
await mkdir(paseoHome, { recursive: true });
const logger = pino({ level: "silent" });
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, logger);
try {
await daemon.start();
const pairing = await generateLocalPairingOffer({
const config: PaseoDaemonConfig = {
listen: socketPath,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
includeQr: false,
});
expect(pairing.relayEnabled).toBe(true);
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, logger);
try {
await daemon.start();
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: true,
relayEndpoint: "127.0.0.1:9",
relayPublicEndpoint: "127.0.0.1:9",
appBaseUrl: "https://app.paseo.sh",
includeQr: false,
});
expect(pairing.relayEnabled).toBe(true);
expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true);
} finally {
await daemon.stop().catch(() => undefined);
await daemon.agentManager.flush().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
},
);
});

View File

@@ -110,10 +110,9 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
const availability = (async (): Promise<boolean> => {
switch (provider) {
case "claude":
return (
(await isCommandAvailable("claude")) &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
const hasClaudeEnvCredentials =
Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials);
case "codex":
return (
(await isCommandAvailable("codex")) &&

View File

@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import pino from "pino";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import pino from "pino";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";

View File

@@ -6,6 +6,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
import { isPlatform } from "../../test-utils/platform.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js";
@@ -220,54 +221,59 @@ test("returns error for non-git directory", async () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns repo info for git repo with branch and dirty state", async () => {
const cwd = tmpCwd();
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
test.skipIf(isPlatform("win32"))(
"returns repo info for git repo with branch and dirty state",
async () => {
const cwd = tmpCwd();
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (makes repo dirty)
writeFileSync(testFile, "modified content\n");
// Modify the file (makes repo dirty)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Test",
});
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
// Verify repo info returned without error
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
// macOS symlinks /var to /private/var, so we check containment
expect(result.repoRoot).toContain("daemon-e2e-");
expect(result.currentBranch).toBeTruthy();
expect(result.isDirty).toBe(true);
// Verify repo info returned without error
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
// macOS symlinks /var to /private/var, so we check containment
expect(result.repoRoot).toContain("daemon-e2e-");
expect(result.currentBranch).toBeTruthy();
expect(result.isDirty).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000,
); // 1 minute timeout
test("returns clean state when no uncommitted changes", async () => {
const cwd = tmpCwd();

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import pino from "pino";
import type { AgentClient } from "../agent/agent-sdk-types.js";
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";

View File

@@ -0,0 +1,58 @@
// POSIX-only: symlink fixtures
/* eslint-disable max-nested-callbacks */
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listDirectoryEntries, readExplorerFile } from "./service.js";
import { isPlatform } from "../../test-utils/platform.js";
async function createTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), prefix));
}
describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "packages", "server"), { recursive: true });
const serverDir = path.join(root, "packages", "server");
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
const result = await listDirectoryEntries({
root,
relativePath: "packages/server",
});
expect(result.path).toBe("packages/server");
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("README.md");
expect(names).not.toContain("AGENTS.md");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
});

View File

@@ -1,8 +1,8 @@
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listDirectoryEntries, readExplorerFile } from "./service.js";
import { readExplorerFile } from "./service.js";
async function createHomeTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.homedir(), prefix));
@@ -13,29 +13,6 @@ async function createTempDir(prefix: string): Promise<string> {
}
describe("file explorer service", () => {
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "packages", "server"), { recursive: true });
const serverDir = path.join(root, "packages", "server");
await writeFile(path.join(serverDir, "README.md"), "# server\n", "utf-8");
await symlink("CLAUDE.md", path.join(serverDir, "AGENTS.md"));
const result = await listDirectoryEntries({
root,
relativePath: "packages/server",
});
expect(result.path).toBe("packages/server");
const names = result.entries.map((entry) => entry.name);
expect(names).toContain("README.md");
expect(names).not.toContain("AGENTS.md");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("reads .ex files as text", async () => {
const root = await createTempDir("paseo-file-explorer-");
@@ -135,25 +112,4 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
});

View File

@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: path.join(paseoHome, "logs", "programmatic.log"),
path: path.resolve(paseoHome, "logs", "programmatic.log"),
},
});
});

View File

@@ -1,6 +1,14 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { randomUUID } from "node:crypto";
import { beforeEach, afterEach, describe, expect, test } from "vitest";
import type {
@@ -24,6 +32,7 @@ import type {
import { AgentStorage } from "./agent/agent-storage.js";
import { AgentManager } from "./agent/agent-manager.js";
import { LoopService } from "./loop-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { createTestLogger } from "../test-utils/test-logger.js";
const TEST_CAPABILITIES: AgentCapabilityFlags = {
@@ -220,60 +229,71 @@ describe("LoopService", () => {
let storage: AgentStorage;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-")));
paseoHome = path.join(tmpDir, "paseo-home");
workspaceDir = path.join(tmpDir, "workspace");
storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
mkdirSync(workspaceDir, { recursive: true });
workspaceDir = realpathSync.native(workspaceDir);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("runs fresh worker agents until verify-check passes", async () => {
const state = { workerRuns: 0 };
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
state.workerRuns += 1;
if (config.title?.includes("worker") && state.workerRuns >= 2) {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
}
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
return '{"passed":true,"reason":"not used"}';
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
// POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box.
test.skipIf(isPlatform("win32"))(
"runs fresh worker agents until verify-check passes",
async () => {
const state = { workerRuns: 0 };
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
state.workerRuns += 1;
if (config.title?.includes("worker") && state.workerRuns >= 2) {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
}
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
return '{"passed":true,"reason":"not used"}';
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt when the task is actually fixed.",
cwd: workspaceDir,
verifyChecks: ["test -f done.txt"],
sleepMs: 1,
maxIterations: 3,
});
const loop = await service.runLoop({
prompt: "Create done.txt when the task is actually fixed.",
cwd: workspaceDir,
verifyChecks: [
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
],
sleepMs: 1,
maxIterations: 3,
});
await waitForLoopCompletion(service, loop.id);
await waitForLoopCompletion(service, loop.id);
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations).toHaveLength(2);
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
expect(finalLoop.iterations[0]?.status).toBe("failed");
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
});
const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations).toHaveLength(2);
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(
finalLoop.iterations[1]?.workerAgentId,
);
expect(finalLoop.iterations[0]?.status).toBe("failed");
expect(finalLoop.iterations[1]?.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
},
);
test("uses worker and verifier provider-model settings when provided", async () => {
const workerConfigs: AgentSessionConfig[] = [];

View File

@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -13,6 +13,7 @@ import {
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { isPlatform } from "../test-utils/platform.js";
const cleanupPaths: string[] = [];
@@ -65,41 +66,45 @@ test("creates a worktree and registers it in the source workspace project withou
]);
});
test("reuses an existing worktree and still upserts the workspace", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const first = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
);
const events: string[] = [];
const deps = createDeps({
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test.skipIf(isPlatform("win32"))(
"reuses an existing worktree and still upserts the workspace",
async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const first = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
);
const events: string[] = [];
const deps = createDeps({
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
});
const second = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
);
const second = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
);
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
});
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
},
);
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
@@ -131,7 +136,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
});
const branchAfterFirst = execSync("git branch --show-current", {
const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -157,7 +162,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
firstAgentContext: { prompt: "Try another name" },
generateBranchNameFromContext: async () => "second-agent-name",
});
const branchAfterSecond = execSync("git branch --show-current", {
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -196,7 +201,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
: null,
});
const branchAfter = execSync("git branch --show-current", {
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -253,7 +258,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
: null,
});
const branchAfter = execSync("git branch --show-current", {
const branchAfter = execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -286,7 +291,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -305,11 +310,11 @@ test("leaves the branch alone when generated branch text is invalid", async () =
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree(
{
@@ -334,7 +339,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -370,7 +375,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
@@ -524,17 +529,24 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
}
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" })
.toString()
.trim();
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
cwd,
stdio: "pipe",
})
const mainRepoRoot = execFileSync(
"git",
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd,
stdio: "pipe",
},
)
.toString()
.trim()
.replace(/\/\.git$/, "");
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
cwd,
stdio: "pipe",
})
.toString()
.trim();
@@ -566,34 +578,39 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
const repoDir = path.join(tempDir, "repo");
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const { tempDir, repoDir } = createGitRepo();
execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" });
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-D", "pr-123"], { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], {
stdio: "pipe",
});
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], {
stdio: "pipe",
});
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -22,16 +22,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return {
tempDir,

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -12,16 +12,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return {
tempDir,

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { ScriptRouteStore } from "./script-proxy.js";
import {
buildWorkspaceScriptPayloads,
@@ -21,16 +21,25 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
stdio: "pipe",
});
return {
tempDir,

View File

@@ -47,6 +47,7 @@ import {
asDaemonConfigStore,
createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js";
interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>;
@@ -705,39 +706,46 @@ describe("project config RPC authorization", () => {
]);
});
test("read_project_config_request accepts a symlink to an active project root", async () => {
const repoRoot = makeRoot();
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
// POSIX-only: creates a directory symlink without Windows privileges.
test.skipIf(isPlatform("win32"))(
"read_project_config_request accepts a symlink to an active project root",
async () => {
const repoRoot = makeRoot();
writeFileSync(
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm ci" } }),
);
const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-symlink-1",
repoRoot: linkRoot,
});
await session.handleMessage({
type: "read_project_config_request",
requestId: "read-symlink-1",
repoRoot: linkRoot,
});
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-symlink-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
expect(messages).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "read-symlink-1",
repoRoot,
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: expect.objectContaining({
mtimeMs: expect.any(Number),
size: expect.any(Number),
}),
},
},
},
]);
});
]);
},
);
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot();

View File

@@ -3007,18 +3007,11 @@ export class Session {
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`);
}
const snapshot = await this.agentManager.createAgent(
{
...sessionConfig,
cwd: resolvedWorkspace.cwd,
},
undefined,
{
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
},
);
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, {
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
});
await this.forwardAgentUpdate(snapshot);
await this.sendInitialCreateAgentPrompt({

View File

@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import path from "node:path";
import type pino from "pino";
import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
@@ -36,6 +37,9 @@ type WorkspaceUpdatePayload = Extract<
{ type: "workspace_update" }
>["payload"];
const REPO_CWD = path.resolve("/tmp/repo");
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
@@ -243,7 +247,7 @@ function seedGitWorkspace(input: {
input.projectId,
createPersistedProjectRecord({
projectId: input.projectId,
rootPath: "/tmp/repo",
rootPath: input.cwd,
displayName: "repo",
kind: "git",
createdAt: "2026-03-01T12:00:00.000Z",
@@ -274,7 +278,7 @@ describe("workspace git watch targets", () => {
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
cwd: REPO_CWD,
name: "main",
});
sessionAny.workspaceUpdatesSubscription = {
@@ -289,22 +293,22 @@ describe("workspace git watch targets", () => {
id: "ws-10",
projectId: "proj-1",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectRootPath: REPO_CWD,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
workspaceDirectory: "/tmp/repo",
workspaceDirectory: REPO_CWD,
};
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" },
{ cwd: REPO_CWD },
expect.any(Function),
);
@@ -314,7 +318,7 @@ describe("workspace git watch targets", () => {
};
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: {
currentBranch: "renamed-branch",
},
@@ -349,7 +353,7 @@ describe("workspace git watch targets", () => {
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
cwd: REPO_CWD,
name: "main",
});
sessionAny.workspaceUpdatesSubscription = {
@@ -360,11 +364,11 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
emitted.length = 0;
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
createWorkspaceRuntimeSnapshot(REPO_CWD, {
git: {
currentBranch: "feature/server-push",
isDirty: true,
@@ -380,9 +384,9 @@ describe("workspace git watch targets", () => {
) as Array<{ type: "checkout_status_update"; payload: CheckoutStatusUpdatePayload }>;
expect(statusUpdates).toHaveLength(1);
expect(statusUpdates[0]?.payload).toMatchObject({
cwd: "/tmp/repo",
cwd: REPO_CWD,
isGit: true,
repoRoot: "/tmp/repo",
repoRoot: REPO_CWD,
currentBranch: "feature/server-push",
isDirty: true,
baseRef: "main",
@@ -393,10 +397,10 @@ describe("workspace git watch targets", () => {
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
error: null,
requestId: "subscription:/tmp/repo",
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
});
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" },
{ cwd: REPO_CWD },
expect.any(Function),
);
@@ -412,7 +416,7 @@ describe("workspace git watch targets", () => {
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
cwd: REPO_CWD,
name: "main",
});
sessionAny.workspaceUpdatesSubscription = {
@@ -423,11 +427,11 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
emitted.length = 0;
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: {
featuresEnabled: true,
pullRequest: {
@@ -457,7 +461,7 @@ describe("workspace git watch targets", () => {
| { payload: CheckoutStatusUpdatePayload }
| undefined;
expect(statusUpdate?.payload.prStatus).toEqual({
cwd: "/tmp/repo",
cwd: REPO_CWD,
status: {
number: 456,
url: "https://github.com/acme/repo/pull/456",
@@ -481,7 +485,7 @@ describe("workspace git watch targets", () => {
},
githubFeaturesEnabled: true,
error: null,
requestId: "subscription:/tmp/repo",
requestId: REPO_SUBSCRIPTION_REQUEST_ID,
});
await session.cleanup();
@@ -491,7 +495,7 @@ describe("workspace git watch targets", () => {
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
workspaceGitService.getSnapshot.mockResolvedValue(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
createWorkspaceRuntimeSnapshot(REPO_CWD, {
github: {
featuresEnabled: true,
pullRequest: {
@@ -508,15 +512,15 @@ describe("workspace git watch targets", () => {
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
cwd: REPO_CWD,
requestId: "req-pr-status",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
expect(
emitted.find((message) => message.type === "checkout_pr_status_response")?.payload,
).toEqual({
cwd: "/tmp/repo",
cwd: REPO_CWD,
status: {
number: undefined,
url: "https://github.com/acme/repo/pull/456",
@@ -543,12 +547,12 @@ describe("workspace git watch targets", () => {
await session.handleMessage({
type: "checkout_pr_status_request",
cwd: "/tmp/repo",
cwd: REPO_CWD,
requestId: "req-pr-cached",
});
expect(workspaceGitService.refresh).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REPO_CWD);
expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined();
});
});

View File

@@ -185,6 +185,18 @@ function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
}
const T0 = "2026-01-01T00:00:00.000Z";
const FOO = path.resolve("/foo");
const FOO_SUB = path.join(FOO, "sub");
const BAR = path.resolve("/bar");
const BAR_BAZ = path.join(BAR, "baz");
const TOOLBOX = path.resolve("/toolbox");
const TOOLBOX_FLOMO = path.join(TOOLBOX, "flomo-cli");
const USERS_DEVELOPER = path.resolve("/Users/me/Developer");
const USERS_PROJECT = path.join(USERS_DEVELOPER, "projects", "foo");
const PROJECTS = path.resolve("/projects");
const SOME_GIT_REPO = path.join(PROJECTS, "some-git-repo");
const PARENT = path.resolve("/parent");
const PARENT_CHILD = path.join(PARENT, "child");
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
@@ -240,13 +252,13 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
// S1. Open a fresh git repo: creates a workspace at the canonical root.
// ─────────────────────────────────────────────────────────────────────────────
test("S1: open fresh git repo creates workspace at canonical root", async () => {
const h = createHarness({ gitRoots: ["/foo"] });
await openProject(h.session, "/foo");
const h = createHarness({ gitRoots: [FOO] });
await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/foo");
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/foo")).toBe(true);
expect(h.workspaces.has(FOO)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -255,12 +267,12 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
// ─────────────────────────────────────────────────────────────────────────────
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
const h = createHarness({});
await openProject(h.session, "/bar");
await openProject(h.session, BAR);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/bar");
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(h.workspaces.has("/bar")).toBe(true);
expect(h.workspaces.has(BAR)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -269,15 +281,15 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
// ─────────────────────────────────────────────────────────────────────────────
test("S3: re-open active workspace by exact path returns the same record", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo"],
workspaces: [gitWorkspace(FOO)],
projects: [gitProject(FOO)],
gitRoots: [FOO],
});
await openProject(h.session, "/foo");
await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo");
expect(resp?.workspace?.id).toBe(FOO);
expect(h.workspaces.size).toBe(1);
expect(h.workspaces.get("/foo")?.archivedAt).toBeNull();
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -286,13 +298,13 @@ test("S3: re-open active workspace by exact path returns the same record", async
// ─────────────────────────────────────────────────────────────────────────────
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo"],
workspaces: [gitWorkspace(FOO)],
projects: [gitProject(FOO)],
gitRoots: [FOO],
});
await openProject(h.session, "/foo/sub");
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo");
expect(resp?.workspace?.id).toBe(FOO);
expect(h.workspaces.size).toBe(1);
});
@@ -302,14 +314,14 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
// ─────────────────────────────────────────────────────────────────────────────
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
const h = createHarness({
workspaces: [dirWorkspace("/bar")],
projects: [dirProject("/bar")],
workspaces: [dirWorkspace(BAR)],
projects: [dirProject(BAR)],
});
await openProject(h.session, "/bar/baz");
await openProject(h.session, BAR_BAZ);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz");
expect(h.workspaces.has("/bar")).toBe(true);
expect(h.workspaces.has("/bar/baz")).toBe(true);
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
expect(h.workspaces.has(BAR)).toBe(true);
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
expect(h.workspaces.size).toBe(2);
});
@@ -320,13 +332,13 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: [TOOLBOX],
});
await openProject(h.session, "/toolbox");
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
await openProject(h.session, TOOLBOX);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -334,15 +346,15 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
// ─────────────────────────────────────────────────────────────────────────────
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo", "/foo/sub"],
workspaces: [gitWorkspace(FOO)],
projects: [gitProject(FOO)],
gitRoots: [FOO, FOO_SUB],
});
await openProject(h.session, "/foo/sub");
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub");
expect(h.workspaces.has("/foo")).toBe(true);
expect(h.workspaces.has("/foo/sub")).toBe(true);
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(h.workspaces.has(FOO)).toBe(true);
expect(h.workspaces.has(FOO_SUB)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -353,12 +365,12 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)],
projects: [dirProject("/Users/me/Developer", archivedAt)],
workspaces: [dirWorkspace(USERS_DEVELOPER, archivedAt)],
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
});
await openProject(h.session, "/Users/me/Developer/projects/foo");
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true);
await openProject(h.session, USERS_PROJECT);
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -369,12 +381,12 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: [TOOLBOX],
});
await openProject(h.session, "/toolbox/flomo-cli");
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt);
await openProject(h.session, TOOLBOX_FLOMO);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -389,18 +401,18 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({
workspaces: [dirWorkspace("/projects", archivedAt)],
projects: [dirProject("/projects", archivedAt)],
gitRoots: ["/projects/some-git-repo"],
workspaces: [dirWorkspace(PROJECTS, archivedAt)],
projects: [dirProject(PROJECTS, archivedAt)],
gitRoots: [SOME_GIT_REPO],
});
await openProject(h.session, "/projects/some-git-repo");
await openProject(h.session, SOME_GIT_REPO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo");
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/projects/some-git-repo")).toBe(true);
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt);
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -411,19 +423,19 @@ test("S10: opening a git repo nested inside an archived non-git directory create
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: [TOOLBOX],
});
await openProject(h.session, "/toolbox");
await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe("/toolbox");
expect(resp?.workspace?.projectId).toBe("/toolbox");
expect(resp?.workspace?.id).toBe(TOOLBOX);
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1);
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -434,12 +446,12 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [dirWorkspace("/parent", archivedAt)],
projects: [dirProject("/parent", archivedAt)],
workspaces: [dirWorkspace(PARENT, archivedAt)],
projects: [dirProject(PARENT, archivedAt)],
});
const found = await asInternals<{
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
}>(h.session).findWorkspaceByDirectory("/parent/child");
}>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
expect(found).toBeNull();
});
@@ -456,11 +468,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
projects: [gitProject(TOOLBOX, archivedAt)],
gitRoots: [TOOLBOX],
});
await openProject(h.session, "/toolbox/flomo-cli");
await openProject(h.session, TOOLBOX_FLOMO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceKind).toBe("directory");

Some files were not shown because too many files have changed in this diff Show More