Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8d1ba7e3f3 Fix Windows packaged CLI TTY prompts 2026-05-07 14:44:44 +07:00
211 changed files with 7765 additions and 12179 deletions
+47 -11
View File
@@ -66,12 +66,7 @@ jobs:
run: npm run typecheck run: npm run typecheck
server-tests: server-tests:
strategy: runs-on: ubuntu-latest
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: server-tests (${{ matrix.os }})
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -88,9 +83,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Build highlight dependency - name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight run: npm run build --workspace=@getpaseo/highlight
@@ -103,6 +95,50 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 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: desktop-tests:
strategy: strategy:
fail-fast: false fail-fast: false
@@ -180,7 +216,7 @@ jobs:
run: npm run build --workspace=@getpaseo/server run: npm run build --workspace=@getpaseo/server
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests - name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app run: npm run test:e2e --workspace=@getpaseo/app
@@ -235,7 +271,7 @@ jobs:
run: npm install run: npm install
- name: Install agent CLIs for provider tests - name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai run: npm install -g @openai/codex@0.105.0 opencode-ai
- name: Build highlight dependency - name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight run: npm run build --workspace=@getpaseo/highlight
-3
View File
@@ -36,9 +36,6 @@ jobs:
- name: Install server dependencies - name: Install server dependencies
run: npm install --workspace=@getpaseo/server --include-workspace-root 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 - name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight run: npm run build --workspace=@getpaseo/highlight
-1
View File
@@ -66,7 +66,6 @@ CLAUDE.local.md
.paseo/ .paseo/
.wrangler/ .wrangler/
**/.wrangler/ **/.wrangler/
**/.tanstack/
# Local agent/tooling artifacts (do not commit) # Local agent/tooling artifacts (do not commit)
PLAN.md PLAN.md
+1 -1
View File
@@ -11,5 +11,5 @@
"arrowParens": "always", "arrowParens": "always",
"bracketSameLine": false, "bracketSameLine": false,
"bracketSpacing": true, "bracketSpacing": true,
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"] "ignorePatterns": ["*.lock"]
} }
-58
View File
@@ -1,63 +1,5 @@
# Changelog # 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 ## 0.1.69 - 2026-05-05
### Fixed ### Fixed
+1 -1
View File
@@ -14,7 +14,7 @@ There are two supported ways to ship from `main`:
Before running any stable patch release command: 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 the intended release commit is already committed to `main` and the working tree is clean.
- **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. - Make sure local `npm run typecheck` passes on that commit.
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready. - Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
```bash ```bash
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Fo95v2pBAW1i0K7WPoEwtKbwjeDZ5ed4vJ5p7I7LIYw="; npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
# Prevent onnxruntime-node's install script from running during automatic # 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). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+779 -914
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.70", "version": "0.1.69",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [ "keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.70", "version": "0.1.69",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -67,22 +67,6 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true); 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", () => { it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
@@ -48,10 +48,7 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
return true; return true;
} }
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""] return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
.join(" ") value.toLowerCase().includes(normalizedQuery),
.toLowerCase(); );
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return tokens.every((token) => haystack.includes(token));
} }
@@ -7,34 +7,12 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
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 CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; 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() { export function IntegrationsSection() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -47,18 +25,16 @@ export function IntegrationsSection() {
} = useCliInstall(); } = useCliInstall();
const { const {
status: skillsStatus, status: skillsStatus,
isWorking: isSkillsWorking, isInstalling: isInstallingSkills,
install: installSkills, install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
refresh: refreshSkillsStatus, refresh: refreshSkillsStatus,
} = useSkillsStatus(); } = useSkillsInstall();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showSection) return undefined; if (!showSection) return undefined;
refreshCliStatus(); refreshCliStatus();
void refreshSkillsStatus(); refreshSkillsStatus();
return undefined; return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]), }, [refreshCliStatus, refreshSkillsStatus, showSection]),
); );
@@ -69,33 +45,9 @@ export function IntegrationsSection() {
}, [installCli, isInstallingCli]); }, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => { const handleInstallSkills = useCallback(() => {
if (isSkillsWorking) return; if (isInstallingSkills) return;
void installSkills(); installSkills();
}, [installSkills, isSkillsWorking]); }, [installSkills, isInstallingSkills]);
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(() => { const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL); void openExternalUrl(CLI_DOCS_URL);
@@ -144,8 +96,6 @@ export function IntegrationsSection() {
return null; return null;
} }
const skillsState = skillsStatus?.state ?? null;
return ( return (
<SettingsSection title="Integrations" trailing={trailing}> <SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
@@ -180,66 +130,27 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text> <Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View> </View>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
{skillsState === "drift" Teach your agents to orchestrate through the CLI
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
</Text> </Text>
</View> </View>
<SkillsActions {skillsStatus?.installed ? (
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}> <View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} /> <Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text> <Text style={styles.mutedText}>Installed</Text>
</View> </View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}> ) : (
Uninstall <Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button> </Button>
)}
</View> </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> </View>
); </SettingsSection>
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
</Button>
); );
} }
@@ -263,9 +174,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
})); }));
@@ -220,67 +220,10 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli")); return parseInstallStatus(await invokeDesktopCommand("install_cli"));
} }
export type SkillsState = "not-installed" | "up-to-date" | "drift"; export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
} }
function parseSkillsState(value: unknown): SkillsState { export async function installSkills(): Promise<InstallStatus> {
switch (value) { return parseInstallStatus(await invokeDesktopCommand("install_skills"));
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"));
} }
@@ -5,7 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCliInstall, useSkillsStatus } from "./use-install-status"; import { useCliInstall, useSkillsInstall } from "./use-install-status";
const toast = vi.hoisted(() => ({ const toast = vi.hoisted(() => ({
error: vi.fn(), error: vi.fn(),
@@ -15,11 +15,9 @@ const toast = vi.hoisted(() => ({
const desktopDaemon = vi.hoisted(() => ({ const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(), getCliInstallStatus: vi.fn(),
getSkillsInstallStatus: vi.fn(),
installCli: vi.fn(), installCli: vi.fn(),
getSkillsStatus: vi.fn(),
installSkills: vi.fn(), installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true), shouldUseDesktopDaemon: vi.fn(() => true),
})); }));
@@ -91,9 +89,11 @@ describe("useCliInstall", () => {
}); });
}); });
describe("useSkillsStatus", () => { describe("useSkillsInstall", () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
}); });
afterEach(() => { afterEach(() => {
@@ -101,155 +101,34 @@ describe("useSkillsStatus", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("loads the current skills status", async () => { it("loads skills install status", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ const { result } = renderDesktopHook(() => useSkillsInstall());
state: "up-to-date",
ops: [],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => { await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); expect(result.current.status).toEqual({ installed: true });
}); });
expect(result.current.isWorking).toBe(false);
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
}); });
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => { it("toasts and exposes skills install errors", 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?.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(() => {
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"); const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsStatus.mockResolvedValue({ desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockRejectedValue(error); desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsInstall());
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => { await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed"); expect(result.current.status).toEqual({ installed: false });
}); });
await act(async () => { act(() => {
await result.current.install(); result.current.install();
}); });
await waitFor(() => { await waitFor(() => {
expect(result.current.error).toBe(error); expect(result.current.error).toBe(error);
}); });
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills."); expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error); expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
}); });
@@ -2,14 +2,11 @@ import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
getCliInstallStatus, getCliInstallStatus,
getSkillsStatus, getSkillsInstallStatus,
installCli, installCli,
installSkills, installSkills,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
type InstallStatus, type InstallStatus,
type SkillsStatus,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { import {
useDesktopIpcErrorReporter, useDesktopIpcErrorReporter,
@@ -17,7 +14,11 @@ import {
} from "@/desktop/hooks/desktop-ipc-error"; } from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const; const SKILLS_INSTALL_STATUS_QUERY_KEY = [
"desktop",
"integrations",
"skills-install-status",
] as const;
interface DesktopInstallHookResult { interface DesktopInstallHookResult {
status: InstallStatus | null; status: InstallStatus | null;
@@ -76,43 +77,25 @@ export function useCliInstall(): DesktopInstallHookResult {
}; };
} }
export interface SkillsStatusHookResult { export function useSkillsInstall(): DesktopInstallHookResult {
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 queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter(); const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<SkillsStatus, Error>({ const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY, queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsStatus, queryFn: getSkillsInstallStatus,
enabled, enabled,
retry: false, retry: false,
}); });
const { data: status, error: statusError, isLoading, refetch } = statusQuery; const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({ useDesktopIpcQueryErrorToast({
error: statusQuery.error, error: statusQuery.error,
message: "Unable to check orchestration skills status.", message: "Unable to check orchestration skills install status.",
logLabel: "[Integrations] Failed to load skills status", logLabel: "[Integrations] Failed to load skills status",
}); });
const setStatus = useCallback( const installMutation = useMutation<InstallStatus, Error>({
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
mutationFn: installSkills, mutationFn: installSkills,
onError: (error) => { onError: (error) => {
reportError({ reportError({
@@ -121,65 +104,23 @@ export function useSkillsStatus(): SkillsStatusHookResult {
logLabel: "[Integrations] Failed to install skills", logLabel: "[Integrations] Failed to install skills",
}); });
}, },
onSuccess: setStatus, onSuccess: (nextStatus) => {
}); queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
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 { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const uninstallMutation = useMutation<SkillsStatus, Error>({ const refresh = useCallback(() => {
mutationFn: uninstallSkills, void refetch();
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]); }, [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 { return {
status: status ?? null, status: installStatus ?? null,
isLoading, isLoading,
isWorking, isInstalling,
error: error: statusError ?? installError ?? null,
statusError ??
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
null,
refresh,
install, install,
update, refresh,
uninstall,
}; };
} }
@@ -184,6 +184,21 @@ describe("UpdateCalloutSource", () => {
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); 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 () => { it("disables the install action and shows Installing... while installing", async () => {
updaterState.value = { updaterState.value = {
...updaterState.value, ...updaterState.value,
@@ -13,18 +13,25 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000; const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog"; const CHANGELOG_URL = "https://paseo.sh/changelog";
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string { function resolveUpdateCalloutTitle(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
}): string {
if (args.isInstalled) return "Update installed";
if (args.isInstalling) return "Installing update"; if (args.isInstalling) return "Installing update";
if (args.isError) return "Update failed"; if (args.isError) return "Update failed";
return "Update available"; return "Update available";
} }
function resolveUpdateCalloutDescription(args: { function resolveUpdateCalloutDescription(args: {
isInstalled: boolean;
isInstalling: boolean; isInstalling: boolean;
isError: boolean; isError: boolean;
errorMessage: string | null; errorMessage: string | null;
latestVersion: string | undefined; latestVersion: string | undefined;
}): ReactNode { }): ReactNode {
if (args.isInstalled) return "Restart to use the new version.";
if (args.isInstalling) return "Installing and restarting..."; if (args.isInstalling) return "Installing and restarting...";
if (args.isError) return args.errorMessage ?? "Something went wrong."; if (args.isError) return args.errorMessage ?? "Something went wrong.";
if (args.latestVersion) { if (args.latestVersion) {
@@ -36,6 +43,7 @@ function resolveUpdateCalloutDescription(args: {
} }
function buildUpdateCalloutActions(args: { function buildUpdateCalloutActions(args: {
isInstalled: boolean;
isInstalling: boolean; isInstalling: boolean;
isError: boolean; isError: boolean;
openChangelog: () => void; openChangelog: () => void;
@@ -45,7 +53,7 @@ function buildUpdateCalloutActions(args: {
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }]; const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
if (args.isError) { if (args.isError) {
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" }); actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
} else { } else if (!args.isInstalled) {
actions.push({ actions.push({
label: args.isInstalling ? "Installing..." : "Install & restart", label: args.isInstalling ? "Installing..." : "Install & restart",
onPress: args.install, onPress: args.install,
@@ -99,21 +107,29 @@ export function UpdateCalloutSource() {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
} }
if (status !== "available" && status !== "installing" && status !== "error") { if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
) {
return; return;
} }
const isInstalled = status === "installed";
const isError = status === "error"; const isError = status === "error";
const isAvailable = !isInstalling && !isError; const isAvailable = !isInstalled && !isInstalling && !isError;
const title = resolveUpdateCalloutTitle({ isInstalling, isError }); const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
const description = resolveUpdateCalloutDescription({ const description = resolveUpdateCalloutDescription({
isInstalled,
isInstalling, isInstalling,
isError, isError,
errorMessage, errorMessage,
latestVersion: availableUpdate?.latestVersion ?? undefined, latestVersion: availableUpdate?.latestVersion ?? undefined,
}); });
const actions = buildUpdateCalloutActions({ const actions = buildUpdateCalloutActions({
isInstalled,
isInstalling, isInstalling,
isError, isError,
openChangelog, openChangelog,
+3 -6
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { import {
Alert, Alert,
@@ -745,19 +745,16 @@ function SettingsSidebar({
) : null} ) : null}
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
{items.map((item) => ( {items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton <SidebarSectionButton
key={item.id}
itemId={item.id} itemId={item.id}
label={item.label} label={item.label}
icon={item.icon} icon={item.icon}
isSelected={selectedSectionId === item.id} isSelected={selectedSectionId === item.id}
onSelect={onSelectSection} onSelect={onSelectSection}
/> />
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))} ))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View> </View>
<SidebarSeparator /> <SidebarSeparator />
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
@@ -20,7 +20,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url"; import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
@@ -28,7 +27,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -40,7 +38,6 @@ interface OpenTarget {
id: string; id: string;
label: string; label: string;
icon: ReactElement; icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void; onOpen: () => Promise<void> | void;
} }
@@ -85,16 +82,14 @@ export function WorkspaceOpenInEditorButton({
const toast = useToast(); const toast = useToast();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldQueryWorkspace = const shouldLoadTargets =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd); isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({ const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId], queryKey: ["available-editors", serverId],
enabled: shouldLoadEditorTargets, enabled: shouldLoadTargets,
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
queryFn: async () => { queryFn: async () => {
@@ -117,7 +112,7 @@ export function WorkspaceOpenInEditorButton({
const { status: checkoutStatus } = useCheckoutStatusQuery({ const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId, serverId,
cwd: shouldQueryWorkspace ? cwd : "", cwd: shouldLoadTargets ? cwd : "",
}); });
const editorTargets = useMemo<OpenTarget[]>( const editorTargets = useMemo<OpenTarget[]>(
@@ -126,7 +121,6 @@ export function WorkspaceOpenInEditorButton({
id: editor.id, id: editor.id,
label: editor.label, label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />, icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => { onOpen: async () => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
@@ -155,20 +149,13 @@ export function WorkspaceOpenInEditorButton({
id: "github", id: "github",
label: "GitHub", label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url), onOpen: () => openExternalUrl(url),
}; };
}, [checkoutStatus]); }, [checkoutStatus]);
const targets = useMemo( const targets = useMemo(
() => () => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
filterTargetsForDaemonLocation( [editorTargets, githubTarget],
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
); );
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]); const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -223,7 +210,7 @@ export function WorkspaceOpenInEditorButton({
} }
}, [primaryOption, handleOpenTarget]); }, [primaryOption, handleOpenTarget]);
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) { if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
return null; return null;
} }
@@ -1,36 +0,0 @@
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 },
]);
});
});
@@ -1,13 +0,0 @@
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);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.70", "version": "0.1.69",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -24,7 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.70", "@getpaseo/server": "0.1.69",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
-5
View File
@@ -60,11 +60,6 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 4.6 1M", model: "Opus 4.6 1M",
descriptionFragment: "1M context window", descriptionFragment: "1M context window",
}, },
{
id: "claude-sonnet-4-6[1m]",
model: "Sonnet 4.6 1M",
descriptionFragment: "1M context window",
},
{ {
id: "claude-sonnet-4-6", id: "claude-sonnet-4-6",
model: "Sonnet 4.6", model: "Sonnet 4.6",
+5 -1
View File
@@ -4,12 +4,16 @@ setlocal
set "SCRIPT_DIR=%~dp0" set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.." set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe" set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
if not exist "%APP_EXECUTABLE%" ( if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1 exit /b 1
) )
if not exist "%CLI_EXECUTABLE%" (
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
)
set "ELECTRON_RUN_AS_NODE=1" set "ELECTRON_RUN_AS_NODE=1"
set "PASEO_NODE_ENV=production" set "PASEO_NODE_ENV=production"
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* "%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel% exit /b %errorlevel%
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.70", "version": "0.1.69",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+46 -14
View File
@@ -4,6 +4,9 @@ const path = require("path");
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
const EXECUTABLE_NAME = "Paseo"; const EXECUTABLE_NAME = "Paseo";
const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli";
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c;
// electron-builder arch enum → Node.js arch string // electron-builder arch enum → Node.js arch string
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" }; const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
@@ -53,24 +56,12 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
function pruneClaudeAgentSdk(nodeModules, platform, arch) { function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor"); const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch]; const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (keepName) { if (!keepName) return;
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName])); pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([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) { function pruneNodePty(nodeModules, platform, arch) {
const prebuilds = path.join(nodeModules, "node-pty", "prebuilds"); const prebuilds = path.join(nodeModules, "node-pty", "prebuilds");
pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`])); pruneChildrenExcept(prebuilds, new Set([`${platform}-${arch}`]));
@@ -133,12 +124,50 @@ function fmtMB(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
} }
function setWindowsExecutableSubsystem(filePath, subsystem) {
const fd = fs.openSync(filePath, "r+");
try {
const dosHeader = Buffer.alloc(64);
fs.readSync(fd, dosHeader, 0, dosHeader.length, 0);
if (dosHeader.toString("ascii", 0, 2) !== "MZ") {
throw new Error(`Invalid Windows executable DOS header: ${filePath}`);
}
const peHeaderOffset = dosHeader.readUInt32LE(0x3c);
const peSignature = Buffer.alloc(4);
fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset);
if (peSignature.toString("ascii") !== "PE\u0000\u0000") {
throw new Error(`Invalid Windows executable PE header: ${filePath}`);
}
const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER;
const subsystemBuffer = Buffer.alloc(2);
subsystemBuffer.writeUInt16LE(subsystem);
fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset);
} finally {
fs.closeSync(fd);
}
}
function createWindowsCliExecutable(appOutDir) {
const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`);
const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`);
fs.copyFileSync(appExecutable, cliExecutable);
setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI);
console.log(`Created Windows CLI executable: ${cliExecutable}`);
}
exports.default = async function afterPack(context) { exports.default = async function afterPack(context) {
const platform = context.electronPlatformName; const platform = context.electronPlatformName;
const arch = ARCH_MAP[context.arch] || process.arch; const arch = ARCH_MAP[context.arch] || process.arch;
pruneNativeModules(context.appOutDir, platform, arch); pruneNativeModules(context.appOutDir, platform, arch);
if (platform === "win32") {
createWindowsCliExecutable(context.appOutDir);
}
if (platform === "linux" || platform === "win32") { if (platform === "linux" || platform === "win32") {
if (arch !== process.arch) { if (arch !== process.arch) {
console.log( console.log(
@@ -159,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
appPath: appOutDir, appPath: appOutDir,
}); });
} }
exports.createWindowsCliExecutable = createWindowsCliExecutable;
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
@@ -17,13 +17,12 @@ import {
downloadAndInstallUpdate, downloadAndInstallUpdate,
type AppReleaseChannel, type AppReleaseChannel,
} from "../features/auto-updater.js"; } from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
import { import {
getSkillsStatus, installCli,
getCliInstallStatus,
installSkills, installSkills,
uninstallSkills, getSkillsInstallStatus,
updateSkills, } from "../integrations/integrations-manager.js";
} from "../integrations/skills/index.js";
import { import {
openLocalTransportSession, openLocalTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
@@ -532,10 +531,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
get_local_daemon_version: () => getLocalDaemonVersion(), get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(), install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(), get_cli_install_status: () => getCliInstallStatus(),
get_skills_status: () => getSkillsStatus(),
install_skills: () => installSkills(), install_skills: () => installSkills(),
update_skills: () => updateSkills(), get_skills_install_status: () => getSkillsInstallStatus(),
uninstall_skills: () => uninstallSkills(),
}; };
} }
@@ -1,9 +1,12 @@
import { readFileSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const require = createRequire(import.meta.url);
describe("desktop packaging", () => { describe("desktop packaging", () => {
it("unpacks server zsh shell integration files for external shells", () => { it("unpacks server zsh shell integration files for external shells", () => {
@@ -33,4 +36,38 @@ describe("desktop packaging", () => {
expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
} }
}); });
it("uses a console-subsystem executable for the bundled Windows CLI shim", () => {
const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8");
expect(cmd).toContain("PaseoCli.exe");
expect(cmd).toContain('"%CLI_EXECUTABLE%"');
});
it("can mark the Windows CLI executable as console subsystem", () => {
const { setWindowsExecutableSubsystem } = require(
join(packageRoot, "scripts", "after-pack.js"),
) as {
setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void;
};
const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-"));
const exePath = join(tempDir, "probe.exe");
const peHeaderOffset = 0x80;
const subsystemOffset = peHeaderOffset + 0x5c;
const bytes = Buffer.alloc(subsystemOffset + 2);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(peHeaderOffset, 0x3c);
bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii");
bytes.writeUInt16LE(2, subsystemOffset);
writeFileSync(exePath, bytes);
try {
setWindowsExecutableSubsystem(exePath, 3);
const patched = readFileSync(exePath);
expect(patched.readUInt16LE(subsystemOffset)).toBe(3);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
}); });
+10 -3
View File
@@ -196,9 +196,16 @@ function buildCheckResult(input: {
}; };
} }
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> { 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(); if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -307,7 +314,7 @@ export async function downloadAndInstallUpdate(
const readyVersion = cachedUpdateInfo.version; const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) { if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(onBeforeQuit); scheduleQuitAndInstall(onBeforeQuit);
return { return {
installed: true, installed: true,
version: readyVersion, version: readyVersion,
@@ -329,7 +336,7 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion; downloadedUpdateVersion = readyVersion;
downloading = false; downloading = false;
await performQuitAndInstall(onBeforeQuit); scheduleQuitAndInstall(onBeforeQuit);
return { return {
installed: true, installed: true,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./path"; import { resolveCliInstallSourcePath } from "./cli-install-path";
describe("cli-install-path", () => { describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => { it("uses the bundled shim for packaged macOS installs", () => {
@@ -1 +0,0 @@
export { getCliInstallStatus, installCli } from "./install.js";
@@ -1,71 +0,0 @@
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) };
}
@@ -1,31 +0,0 @@
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);
}
@@ -1,97 +0,0 @@
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 };
}
}
@@ -0,0 +1,318 @@
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) };
}
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { removeSkill, syncSkills } from "./sync"; import { syncSkills } from "./skill-sync";
interface Sandbox { interface Sandbox {
root: string; root: string;
@@ -70,11 +70,6 @@ describe("syncSkills", () => {
"utf-8", "utf-8",
); );
expect(agentsContent).toBe("new paseo content"); 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( const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"), path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8", "utf-8",
@@ -112,10 +107,10 @@ describe("syncSkills", () => {
), ),
).toBe("roles content"); ).toBe("roles content");
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic"); const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true); const lstat = await fs.lstat(claudeLink);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content"); expect(lstat.isSymbolicLink()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe( expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
"roles content", "roles content",
); );
}); });
@@ -259,46 +254,3 @@ describe("syncSkills", () => {
expect(result.processedSkills).toBe(0); 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();
});
});
@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
claudeDir: string; claudeDir: string;
codexDir: string; codexDir: string;
skillNames: readonly string[]; skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void; onSkillError?: (skillName: string, error: unknown) => void;
} }
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
return true; return true;
} }
export async function listFilesRecursive(rootDir: string): Promise<string[]> { async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = []; const out: string[] = [];
async function walk(dir: string): Promise<void> { async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
return changed; return changed;
} }
export interface RemoveSkillTargets { async function ensureClaudeSkillLink(
agentsDir: string; skillName: string,
claudeDir: string; agentsDir: string,
codexDir: 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);
// 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> { await fs.symlink(target, linkPath);
const paths = [ return 0;
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 });
}
} }
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> { export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0; let changedFiles = 0;
let processedSkills = 0; let processedSkills = 0;
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
path.join(options.agentsDir, skillName), path.join(options.agentsDir, skillName),
); );
changedFiles += await syncDirectoryFiles( changedFiles += await ensureClaudeSkillLink(
bundleSkillDir, skillName,
path.join(options.claudeDir, skillName), options.agentsDir,
options.claudeDir,
platform,
); );
changedFiles += await syncDirectoryFiles( changedFiles += await syncDirectoryFiles(
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
processedSkills++; processedSkills++;
} catch (error) { } catch (error) {
if (!options.onSkillError) throw error; options.onSkillError?.(skillName, error);
options.onSkillError(skillName, error);
} }
} }
@@ -1,10 +0,0 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";
@@ -1,302 +0,0 @@
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);
}
});
});
@@ -1,164 +0,0 @@
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);
}
@@ -1,22 +0,0 @@
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");
}
+5
View File
@@ -48,6 +48,7 @@ import {
createBeforeQuitHandler, createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
@@ -660,6 +661,10 @@ async function bootstrap(): Promise<void> {
registerNotificationHandlers(); registerNotificationHandlers();
registerOpenerHandlers(); registerOpenerHandlers();
void autoUpdateSkillsIfInstalled().catch((error) => {
log.warn("[integrations] auto-update skills failed", error);
});
await createMainWindow(); await createMainWindow();
app.on("activate", async () => { app.on("activate", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.70", "version": "0.1.69",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.70", "version": "0.1.69",
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.70", "version": "0.1.69",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist" "dist"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.70", "version": "0.1.69",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -57,9 +57,9 @@
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.70", "@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.70", "@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
@@ -4,7 +4,6 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest"; 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 repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
@@ -117,10 +116,7 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("raw stderr line\n"); expect(result.log).toContain("raw stderr line\n");
}); });
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL. test("logs worker signal exits even when the worker cannot log", async () => {
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
async () => {
const result = await runSupervisorFixture({ const result = await runSupervisorFixture({
workerSource: ` workerSource: `
process.kill(process.pid, "SIGKILL"); process.kill(process.pid, "SIGKILL");
@@ -132,6 +128,5 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain('"msg":"Worker exited"'); expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"'); expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting"); expect(result.log).toContain("Supervisor exiting");
}, });
);
}); });
@@ -13,13 +13,15 @@ import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js"; import { AgentStorage } from "./agent-storage.js";
import { createAgentMcpServer } from "./mcp-server.js"; import { createAgentMcpServer } from "./mcp-server.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js"; import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
import pino from "pino"; import pino from "pino";
const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
const hasOpenAICredentials = !!process.env.OPENAI_API_KEY; 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 { interface AgentMcpServerHandle {
url: string; url: string;
@@ -145,20 +147,13 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
}; };
} }
describe("getStructuredAgentResponse (e2e)", () => { (shouldRun ? describe : describe.skip)("getStructuredAgentResponse (e2e)", () => {
let manager: AgentManager; let manager: AgentManager;
let cwd: string; let cwd: string;
let agentMcpServer: AgentMcpServerHandle; let agentMcpServer: AgentMcpServerHandle;
let canRunCodex = false;
let canRunClaude = false;
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
beforeAll(async () => { beforeAll(async () => {
canRunCodex = !process.env.CI && hasOpenAICredentials;
canRunClaude = await isProviderAvailable("claude");
if (!canRunCodex && !canRunClaude) {
return;
}
agentMcpServer = await startAgentMcpServer(logger); agentMcpServer = await startAgentMcpServer(logger);
}); });
@@ -179,10 +174,9 @@ describe("getStructuredAgentResponse (e2e)", () => {
await shutdownProviders(logger); await shutdownProviders(logger);
}, 60000); }, 60000);
test("returns schema-valid JSON from a real Codex agent", async (context) => { test.runIf(hasOpenAICredentials)(
if (!canRunCodex) { "returns schema-valid JSON from a real Codex agent",
context.skip(); async () => {
}
const schema = z.object({ const schema = z.object({
title: z.string(), title: z.string(),
count: z.number(), count: z.number(),
@@ -204,12 +198,13 @@ describe("getStructuredAgentResponse (e2e)", () => {
expect(result.title.length).toBeGreaterThan(0); expect(result.title.length).toBeGreaterThan(0);
expect(typeof result.count).toBe("number"); expect(typeof result.count).toBe("number");
}, 180000); },
180000,
);
test("returns schema-valid JSON from Claude Haiku", async (context) => { test.runIf(hasClaudeCredentials)(
if (!canRunClaude) { "returns schema-valid JSON from Claude Haiku",
context.skip(); async () => {
}
const schema = z.object({ const schema = z.object({
message: z.string(), message: z.string(),
}); });
@@ -244,5 +239,7 @@ describe("getStructuredAgentResponse (e2e)", () => {
} }
expect(result.message.trim().toLowerCase()).toBe("hello"); expect(result.message.trim().toLowerCase()).toBe("hello");
}, 180000); },
180000,
);
}); });
@@ -1,8 +1,7 @@
import { execFileSync } from "node:child_process"; import { execSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { z } from "zod"; import { z } from "zod";
@@ -25,9 +24,6 @@ import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js"; import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
interface LooseSafeParseResult { interface LooseSafeParseResult {
success: boolean; success: boolean;
data: unknown; data: unknown;
@@ -46,7 +42,7 @@ interface LooseStructuredContent {
interface RegisteredMcpTool { interface RegisteredMcpTool {
inputSchema: LooseInputSchema; inputSchema: LooseInputSchema;
handler: (input: unknown) => Promise<{ callback: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent; structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>; content?: Array<{ type: string; text?: string }>;
}>; }>;
@@ -387,7 +383,7 @@ describe("terminal MCP tools", () => {
}); });
const tool = registeredTool(server, "capture_terminal"); const tool = registeredTool(server, "capture_terminal");
const response = await tool.handler({ const response = await tool.callback({
terminalId: "term-1", terminalId: "term-1",
scrollback: true, scrollback: true,
stripAnsi: false, stripAnsi: false,
@@ -511,7 +507,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithEmptyProvider.success).toBe(false); expect(providerWithEmptyProvider.success).toBe(false);
await expect( await expect(
tool.handler({ tool.callback({
cwd: existingCwd, cwd: existingCwd,
mode: "default", mode: "default",
title: "Short title", title: "Short title",
@@ -574,7 +570,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await expect( await expect(
tool.handler({ tool.callback({
cwd: "/path/that/does/not/exist", cwd: "/path/that/does/not/exist",
title: "Short title", title: "Short title",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -587,7 +583,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123", id: "agent-123",
cwd: REPO_CWD, cwd: "/tmp/repo",
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -596,7 +592,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: existingCwd, cwd: existingCwd,
title: " Fix auth bug ", title: " Fix auth bug ",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -617,7 +613,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-456", id: "agent-456",
cwd: REPO_CWD, cwd: "/tmp/repo",
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -626,7 +622,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: existingCwd, cwd: existingCwd,
title: " Fix auth ", title: " Fix auth ",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -646,7 +642,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-789", id: "agent-789",
cwd: REPO_CWD, cwd: "/tmp/repo",
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -655,7 +651,7 @@ describe("create_agent MCP tool", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: existingCwd, cwd: existingCwd,
title: "Config test", title: "Config test",
mode: "auto", mode: "auto",
@@ -689,20 +685,14 @@ describe("create_agent MCP tool", () => {
const startedAgentSetupIds: string[] = []; const startedAgentSetupIds: string[] = [];
try { try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config commit.gpgsign false", { 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"); await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree", id: "agent-with-worktree",
@@ -727,7 +717,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: repoDir, cwd: repoDir,
title: "Worktree agent", title: "Worktree agent",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -767,20 +757,14 @@ describe("create_agent MCP tool", () => {
}; };
try { try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config commit.gpgsign false", { 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"); await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree", id: "agent-auto-named-worktree",
@@ -803,7 +787,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: repoDir, cwd: repoDir,
title: "Worktree agent", title: "Worktree agent",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -814,10 +798,7 @@ describe("create_agent MCP tool", () => {
}); });
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const initialBranch = execFileSync("git", ["branch", "--show-current"], { const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
cwd: agentCwd,
stdio: "pipe",
})
.toString() .toString()
.trim(); .trim();
expect(initialBranch).not.toBe(""); expect(initialBranch).not.toBe("");
@@ -843,28 +824,19 @@ describe("create_agent MCP tool", () => {
}; };
try { try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config commit.gpgsign false", { 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"); await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "-b", "existing-feature"], { execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "feature.txt"), "feature\n"); await writeFile(join(repoDir, "feature.txt"), "feature\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" }); execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree", id: "agent-checkout-worktree",
@@ -887,7 +859,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: repoDir, cwd: repoDir,
title: "Checkout agent", title: "Checkout agent",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -899,9 +871,7 @@ describe("create_agent MCP tool", () => {
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
expect( expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
.toString()
.trim(),
).toBe("existing-feature"); ).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -931,7 +901,7 @@ describe("create_agent MCP tool", () => {
}, },
workspace: { workspace: {
workspaceId: "/tmp/worktrees/pr-123", workspaceId: "/tmp/worktrees/pr-123",
projectId: REPO_CWD, projectId: "/tmp/repo",
cwd: "/tmp/worktrees/pr-123", cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const, kind: "worktree" as const,
displayName: "pr-123", displayName: "pr-123",
@@ -939,7 +909,7 @@ describe("create_agent MCP tool", () => {
updatedAt: "2026-04-30T00:00:00.000Z", updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null, archivedAt: null,
}, },
repoRoot: REPO_CWD, repoRoot: "/tmp/repo",
created: true, created: true,
...(options?.setupContinuation?.kind === "agent" ...(options?.setupContinuation?.kind === "agent"
? { ? {
@@ -978,8 +948,8 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: REPO_CWD, cwd: "/tmp/repo",
title: "PR agent", title: "PR agent",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt", initialPrompt: "Rename this PR branch from prompt",
@@ -1015,20 +985,14 @@ describe("create_agent MCP tool", () => {
const setupContinuations: Array<"workspace" | "agent" | undefined> = []; const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try { try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config commit.gpgsign false", { 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"); await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => null), getSnapshot: vi.fn(async () => null),
}; };
@@ -1049,7 +1013,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_worktree"); const tool = registeredTool(server, "create_worktree");
const response = await tool.handler({ const response = await tool.callback({
cwd: repoDir, cwd: repoDir,
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" }, target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
}); });
@@ -1067,27 +1031,19 @@ describe("create_agent MCP tool", () => {
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native( const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
);
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo"); const paseoHome = join(tempDir, ".paseo");
try { try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config commit.gpgsign false", { 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"); await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => null), getSnapshot: vi.fn(async () => null),
@@ -1116,13 +1072,13 @@ describe("create_agent MCP tool", () => {
}); });
const createTool = registeredTool(server, "create_worktree"); const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree"); const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({ const created = await createTool.callback({
cwd: repoDir, cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" }, target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
}); });
workspaceGitService.getSnapshot.mockClear(); workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({ await archiveTool.callback({
cwd: repoDir, cwd: repoDir,
worktreePath: created.structuredContent.worktreePath, worktreePath: created.structuredContent.worktreePath,
}); });
@@ -1170,9 +1126,9 @@ describe("create_agent MCP tool", () => {
}); });
const tool = registeredTool(server, "list_worktrees"); const tool = registeredTool(server, "list_worktrees");
const response = await tool.handler({ cwd: REPO_CWD }); const response = await tool.callback({ cwd: "/tmp/repo" });
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, { expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
reason: "mcp:list-worktrees", reason: "mcp:list-worktrees",
}); });
expect(response.structuredContent.worktrees).toEqual([ expect(response.structuredContent.worktrees).toEqual([
@@ -1232,7 +1188,7 @@ describe("create_agent MCP tool", () => {
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: "subdir", cwd: "subdir",
title: "Child", title: "Child",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -1258,7 +1214,7 @@ describe("create_agent MCP tool", () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-injected-123", id: "agent-injected-123",
cwd: REPO_CWD, cwd: "/tmp/repo",
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
@@ -1271,7 +1227,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
cwd: existingCwd, cwd: existingCwd,
title: "Injected config test", title: "Injected config test",
mode: "auto", mode: "auto",
@@ -1295,7 +1251,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await expect( await expect(
tool.handler({ tool.callback({
cwd: existingCwd, cwd: existingCwd,
title: "Bad mode", title: "Bad mode",
provider: "opencode/gpt-5.4", provider: "opencode/gpt-5.4",
@@ -1332,7 +1288,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
title: "Child", title: "Child",
provider: "claude/claude-sonnet-4-20250514", provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work", initialPrompt: "Do work",
@@ -1363,7 +1319,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await expect( await expect(
tool.handler({ tool.callback({
title: "Child", title: "Child",
provider: "opencode/gpt-5.4", provider: "opencode/gpt-5.4",
initialPrompt: "Do work", initialPrompt: "Do work",
@@ -1398,7 +1354,7 @@ describe("create_agent MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "create_agent"); const tool = registeredTool(server, "create_agent");
await tool.handler({ await tool.callback({
title: "Child", title: "Child",
provider: "opencode/gpt-5.4", provider: "opencode/gpt-5.4",
mode: "build", mode: "build",
@@ -1428,7 +1384,7 @@ describe("create_schedule MCP tool", () => {
const tool = registeredTool(server, "create_schedule"); const tool = registeredTool(server, "create_schedule");
await expect( await expect(
tool.handler({ tool.callback({
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
name: "Default schedule", name: "Default schedule",
@@ -1448,12 +1404,12 @@ describe("create_schedule MCP tool", () => {
}); });
const tool = registeredTool(server, "create_schedule"); const tool = registeredTool(server, "create_schedule");
await tool.handler({ await tool.callback({
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
provider: "codex", provider: "codex",
}); });
await tool.handler({ await tool.callback({
prompt: "say hello again", prompt: "say hello again",
every: "10m", every: "10m",
provider: "codex/gpt-5.4", provider: "codex/gpt-5.4",
@@ -1514,7 +1470,7 @@ describe("provider listing MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "list_providers"); const tool = registeredTool(server, "list_providers");
const response = await tool.handler({}); const response = await tool.callback({});
expect(response.structuredContent).toEqual({ expect(response.structuredContent).toEqual({
providers: [ providers: [
@@ -1562,7 +1518,7 @@ describe("provider listing MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "list_providers"); const tool = registeredTool(server, "list_providers");
const response = await tool.handler({}); const response = await tool.callback({});
expect(response.structuredContent).toEqual({ expect(response.structuredContent).toEqual({
providers: [ providers: [
@@ -1599,7 +1555,7 @@ describe("provider listing MCP tool", () => {
}); });
const tool = registeredTool(server, "list_providers"); const tool = registeredTool(server, "list_providers");
await tool.handler({}); await tool.callback({});
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1); expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
expect(isAvailable).toHaveBeenCalledTimes(1); expect(isAvailable).toHaveBeenCalledTimes(1);
@@ -1635,7 +1591,7 @@ describe("model listing MCP tool", () => {
}); });
const tool = registeredTool(server, "list_models"); const tool = registeredTool(server, "list_models");
await expect(tool.handler({ provider: "codex" })).rejects.toThrow( await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled", "Provider 'codex' is disabled",
); );
expect(fetchModels).not.toHaveBeenCalled(); expect(fetchModels).not.toHaveBeenCalled();
@@ -1659,7 +1615,7 @@ describe("speak MCP tool", () => {
const tool = registeredTool(server, "speak"); const tool = registeredTool(server, "speak");
expect(tool).toBeDefined(); expect(tool).toBeDefined();
await tool.handler({ text: "Hello from voice agent." }); await tool.callback({ text: "Hello from voice agent." });
expect(speak).toHaveBeenCalledWith( expect(speak).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
text: "Hello from voice agent.", text: "Hello from voice agent.",
@@ -1679,7 +1635,7 @@ describe("speak MCP tool", () => {
logger, logger,
}); });
const tool = registeredTool(server, "speak"); const tool = registeredTool(server, "speak");
await expect(tool.handler({ text: "Hello." })).rejects.toThrow( await expect(tool.callback({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for caller agent", "No speak handler registered for caller agent",
); );
}); });
@@ -1706,7 +1662,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({ createManagedAgent({
id: "agent-compact", id: "agent-compact",
provider: "codex", provider: "codex",
cwd: REPO_CWD, cwd: "/tmp/repo",
config: { model: "gpt-5.4", thinkingOptionId: "high" }, config: { model: "gpt-5.4", thinkingOptionId: "high" },
runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" }, runtimeInfo: { provider: "codex", sessionId: "session-123", model: "gpt-5.4" },
labels: { role: "researcher" }, labels: { role: "researcher" },
@@ -1715,7 +1671,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({}); const response = await tool.callback({});
const structured = z const structured = z
.object({ agents: z.array(z.record(z.unknown())) }) .object({ agents: z.array(z.record(z.unknown())) })
.parse(response.structuredContent); .parse(response.structuredContent);
@@ -1731,7 +1687,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: "high", thinkingOptionId: "high",
effectiveThinkingOptionId: "high", effectiveThinkingOptionId: "high",
status: "idle", status: "idle",
cwd: REPO_CWD, cwd: "/tmp/repo",
createdAt: expect.any(String), createdAt: expect.any(String),
updatedAt: expect.any(String), updatedAt: expect.any(String),
lastUserMessageAt: null, lastUserMessageAt: null,
@@ -1769,7 +1725,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "get_agent_status"); const tool = registeredTool(server, "get_agent_status");
const response = await tool.handler({ agentId: "archived-agent" }); const response = await tool.callback({ agentId: "archived-agent" });
expect(response.structuredContent).toEqual({ expect(response.structuredContent).toEqual({
status: "closed", status: "closed",
@@ -1825,7 +1781,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "get_agent_status"); const tool = registeredTool(server, "get_agent_status");
const response = await tool.handler({ agentId: "full-detail-agent" }); const response = await tool.callback({ agentId: "full-detail-agent" });
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot); const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot); const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
@@ -1901,7 +1857,7 @@ describe("agent snapshot MCP serialization", () => {
}); });
const tool = registeredTool(server, "get_agent_status"); const tool = registeredTool(server, "get_agent_status");
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow( await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow(
"Agent internal-agent not found", "Agent internal-agent not found",
); );
}); });
@@ -1945,7 +1901,7 @@ describe("agent snapshot MCP serialization", () => {
callerAgentId: "caller-agent", callerAgentId: "caller-agent",
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({}); const response = await tool.callback({});
const agentIds = agentsOf(response).map((agent) => agent.id); const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(3); expect(agentIds).toHaveLength(3);
@@ -1960,32 +1916,28 @@ describe("agent snapshot MCP serialization", () => {
spies.agentManager.listAgents.mockReturnValue([ spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({ createManagedAgent({
id: "running-target", id: "running-target",
cwd: TARGET_CWD, cwd: "/tmp/target",
lifecycle: "running", lifecycle: "running",
updatedAt: new Date(recent), updatedAt: new Date(recent),
}), }),
createManagedAgent({ createManagedAgent({
id: "idle-target", id: "idle-target",
cwd: TARGET_CWD, cwd: "/tmp/target",
lifecycle: "idle", lifecycle: "idle",
updatedAt: new Date(recent), updatedAt: new Date(recent),
}), }),
createManagedAgent({ createManagedAgent({
id: "old-running-target", id: "old-running-target",
cwd: TARGET_CWD, cwd: "/tmp/target",
lifecycle: "running", lifecycle: "running",
createdAt: new Date(old), createdAt: new Date(old),
updatedAt: new Date(old), updatedAt: new Date(old),
}), }),
]); ]);
spies.agentStorage.list.mockResolvedValue([ spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }), createStoredRecord({ id: "recent-archived", cwd: "/tmp/target", archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }), createStoredRecord({ id: "old-archived", cwd: "/tmp/target", archivedAt: old }),
createStoredRecord({ createStoredRecord({ id: "recent-other-cwd", cwd: "/tmp/other", archivedAt: recent }),
id: "recent-other-cwd",
cwd: resolvePath("/tmp/other"),
archivedAt: recent,
}),
]); ]);
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -1997,8 +1949,8 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ const response = await tool.callback({
cwd: TARGET_CWD, cwd: "/tmp/target",
includeArchived: true, includeArchived: true,
sinceHours: 48, sinceHours: 48,
statuses: ["running", "closed"], statuses: ["running", "closed"],
@@ -2038,7 +1990,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ includeArchived: true }); const response = await tool.callback({ includeArchived: true });
const agentIds = agentsOf(response).map((agent) => agent.id); const agentIds = agentsOf(response).map((agent) => agent.id);
expect(agentIds).toHaveLength(50); expect(agentIds).toHaveLength(50);
@@ -2057,7 +2009,7 @@ describe("agent snapshot MCP serialization", () => {
spies.agentStorage.list.mockResolvedValue([ spies.agentStorage.list.mockResolvedValue([
createStoredRecord({ createStoredRecord({
id: "stored-archived-compact", id: "stored-archived-compact",
cwd: REPO_CWD, cwd: "/tmp/repo",
updatedAt: now, updatedAt: now,
lastActivityAt: now, lastActivityAt: now,
archivedAt: now, archivedAt: now,
@@ -2081,7 +2033,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true }); const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
const item = agentsOf(response)[0]; const item = agentsOf(response)[0];
expect(item).toEqual({ expect(item).toEqual({
@@ -2093,7 +2045,7 @@ describe("agent snapshot MCP serialization", () => {
thinkingOptionId: null, thinkingOptionId: null,
effectiveThinkingOptionId: null, effectiveThinkingOptionId: null,
status: "closed", status: "closed",
cwd: REPO_CWD, cwd: "/tmp/repo",
createdAt: "2026-04-11T00:00:00.000Z", createdAt: "2026-04-11T00:00:00.000Z",
updatedAt: now, updatedAt: now,
lastUserMessageAt: null, lastUserMessageAt: null,
@@ -2154,7 +2106,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({}); const response = await tool.callback({});
expect(agentsOf(response).map((agent) => agent.id)).toEqual([ expect(agentsOf(response).map((agent) => agent.id)).toEqual([
"idle-attention-oldest", "idle-attention-oldest",
@@ -2189,7 +2141,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "list_agents"); const tool = registeredTool(server, "list_agents");
const response = await tool.handler({ includeArchived: true }); const response = await tool.callback({ includeArchived: true });
const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents); const parsed = z.array(AgentListItemPayloadSchema).safeParse(response.structuredContent.agents);
if (!parsed.success) { if (!parsed.success) {
@@ -2229,7 +2181,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "get_agent_activity"); const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "archived-activity-agent" }); const response = await tool.callback({ agentId: "archived-activity-agent" });
expect(response.structuredContent).toEqual( expect(response.structuredContent).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -2267,7 +2219,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "get_agent_activity"); const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 }); const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
const content = String(response.structuredContent.content); const content = String(response.structuredContent.content);
expect(content).toContain("Hello world. How are you?"); expect(content).toContain("Hello world. How are you?");
@@ -2298,7 +2250,7 @@ describe("agent snapshot MCP serialization", () => {
}, },
}); });
const tool = registeredTool(server, "get_agent_activity"); const tool = registeredTool(server, "get_agent_activity");
const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 }); const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
const content = String(response.structuredContent.content); const content = String(response.structuredContent.content);
expect(content).toContain("[User] u3"); expect(content).toContain("[User] u3");
@@ -40,7 +40,7 @@ vi.mock("../../utils/executable.js", () => ({
isCommandAvailable: mockState.isCommandAvailable, isCommandAvailable: mockState.isCommandAvailable,
})); }));
vi.mock("./providers/claude/agent.js", () => ({ vi.mock("./providers/claude-agent.js", () => ({
ClaudeAgentClient: class ClaudeAgentClient { ClaudeAgentClient: class ClaudeAgentClient {
readonly capabilities = { readonly capabilities = {
supportsStreaming: true, supportsStreaming: true,
@@ -21,7 +21,7 @@ import type {
ProviderProfileModel, ProviderProfileModel,
ProviderRuntimeSettings, ProviderRuntimeSettings,
} from "./provider-launch-config.js"; } 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 { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
@@ -6,7 +6,9 @@
* *
* All tests use REAL Claude SDK sessions — no mocks. * All tests use REAL Claude SDK sessions — no mocks.
* *
* These tests run when the shared Claude provider availability gate passes. * 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.
*/ */
import { beforeAll, beforeEach, expect, test } from "vitest"; import { beforeAll, beforeEach, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
@@ -15,8 +17,8 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "../claude-agent.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -24,6 +26,8 @@ import { ClaudeAgentClient } from "./agent.js";
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function tmpCwd(prefix: string): string { function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix)); return mkdtempSync(path.join(tmpdir(), prefix));
@@ -70,14 +74,7 @@ async function createSession(params?: {
async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> { async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise<void> {
await handle.session.close().catch(() => undefined); await handle.session.close().catch(() => undefined);
try { rmSync(handle.cwd, { recursive: true, force: true });
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( async function startTurnAndCollectEvents(
@@ -199,7 +196,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
let canRun = false; let canRun = false;
beforeAll(async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude"); canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
}); });
beforeEach((context) => { beforeEach((context) => {
@@ -1,6 +1,4 @@
import { type ChildProcess } from "node:child_process"; import { describe, expect, test, vi } from "vitest";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { import type {
PermissionOption, PermissionOption,
PromptResponse, PromptResponse,
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js"; import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
interface ACPSessionInternals { interface ACPSessionInternals {
sessionId: string | null; sessionId: string | null;
@@ -117,14 +114,6 @@ 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( function selectConfigOption(
category: "mode" | "model" | "thought_level", category: "mode" | "model" | "thought_level",
values: string[], values: string[],
@@ -314,78 +303,6 @@ 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", () => { describe("mapACPUsage", () => {
test("maps ACP usage fields into Paseo usage", () => { test("maps ACP usage fields into Paseo usage", () => {
expect( expect(
@@ -92,7 +92,7 @@ import {
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js"; import { findExecutable } from "../../../utils/executable.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes( function assertChildWithPipes(
child: ChildProcess, child: ChildProcess,
@@ -106,22 +106,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value); 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 = { const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
@@ -1480,8 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const env = Object.fromEntries( const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]), (params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
); );
const terminalCommand = resolveTerminalCommand(params.command, params.args); const child = spawnProcess(params.command, params.args ?? [], {
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd, cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({ ...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
@@ -1496,7 +1479,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolveExit = resolve; resolveExit = resolve;
rejectExit = reject; rejectExit = reject;
}); });
waitForExit.catch(() => undefined);
const entry: TerminalEntry = { const entry: TerminalEntry = {
id: terminalId, id: terminalId,
@@ -1516,11 +1498,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr!.on("data", (chunk: Buffer | string) => child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()), appendTerminalOutput(entry, chunk.toString()),
); );
child.once("error", (error) => { child.once("error", (error) =>
const spawnError = error instanceof Error ? error : new Error(String(error)); rejectExit(error instanceof Error ? error : new Error(String(error))),
appendTerminalOutput(entry, `${spawnError.message}\n`); );
rejectExit(spawnError);
});
child.once("exit", (code, signal) => { child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal }; const exit = { exitCode: code, signal };
entry.exit = exit; entry.exit = exit;
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"; 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. // Fake-daemon plumbing coverage: validates manager/client command wiring without a real Claude binary.
describe("claude agent commands E2E", () => { describe("claude agent commands E2E", () => {
@@ -1,15 +1,15 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => { describe("claude agent commands contract (real)", () => {
let canRun = false; let canRun = false;
beforeAll(async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude"); canRun = await isCommandAvailable("claude");
}); });
beforeEach((context) => { beforeEach((context) => {
@@ -19,6 +19,8 @@ describe("claude agent commands contract (real)", () => {
}); });
test("lists slash commands with the expected contract", async () => { test("lists slash commands with the expected contract", async () => {
expect(await isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
}); });
@@ -1,10 +1,9 @@
import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../../agent-sdk-types.js"; import type { AgentLaunchContext } from "../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import type { ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query { function createQueryMock(events: unknown[]): Query {
let index = 0; let index = 0;
@@ -37,7 +36,7 @@ describe("Claude SDK env", () => {
PASEO_TEST_FLAG: "launch-value", PASEO_TEST_FLAG: "launch-value",
}, },
}; };
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
capturedEnv = options.env; capturedEnv = options.env;
return createQueryMock([ return createQueryMock([
{ {
@@ -67,7 +66,6 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: createTestLogger(), logger: createTestLogger(),
queryFactory, queryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.createSession( const session = await client.createSession(
{ {
@@ -95,7 +93,7 @@ describe("Claude SDK env", () => {
PASEO_TEST_FLAG: "resume-launch-value", PASEO_TEST_FLAG: "resume-launch-value",
}, },
}; };
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
capturedEnv = options.env; capturedEnv = options.env;
return createQueryMock([ return createQueryMock([
{ {
@@ -125,7 +123,6 @@ describe("Claude SDK env", () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: createTestLogger(), logger: createTestLogger(),
queryFactory, queryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.resumeSession( const session = await client.resumeSession(
{ {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { extractUserMessageText } from "./agent.js"; import { extractUserMessageText } from "./claude-agent.js";
describe("extractUserMessageText", () => { describe("extractUserMessageText", () => {
test("returns trimmed string content", () => { test("returns trimmed string content", () => {
@@ -0,0 +1,476 @@
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,
);
});
@@ -1,9 +1,9 @@
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
interface QueryMock { interface QueryMock {
next: ReturnType<typeof vi.fn>; next: ReturnType<typeof vi.fn>;
@@ -41,7 +41,13 @@ type PromptHandler = (input: {
query: ScriptedQuery; query: ScriptedQuery;
}) => void | Promise<void>; }) => void | Promise<void>;
const queryFactory = vi.fn(); const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
function createAsyncQueue<T>(): AsyncQueue<T> { function createAsyncQueue<T>(): AsyncQueue<T> {
const items: T[] = []; const items: T[] = [];
@@ -239,14 +245,14 @@ async function waitFor(
} }
afterEach(() => { afterEach(() => {
queryFactory.mockReset(); sdkMocks.query.mockReset();
}); });
test("interrupt only calls query.interrupt and leaves the query open", async () => { test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const queries: ScriptedQuery[] = []; const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({ const scriptedQuery = createScriptedQuery({
prompt, prompt,
sessionId: "interrupt-keep-query-session", sessionId: "interrupt-keep-query-session",
@@ -255,11 +261,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
return scriptedQuery; return scriptedQuery;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -272,7 +274,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
await session.interrupt(); await session.interrupt();
await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1);
expect(queryFactory).toHaveBeenCalledTimes(1); expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.return).not.toHaveBeenCalled(); expect(queries[0]?.return).not.toHaveBeenCalled();
const firstTurnEvents = await collectUntilTerminal(firstTurn); const firstTurnEvents = await collectUntilTerminal(firstTurn);
@@ -289,7 +291,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
const logger = createTestLogger(); const logger = createTestLogger();
const queries: ScriptedQuery[] = []; const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({ const scriptedQuery = createScriptedQuery({
prompt, prompt,
sessionId: "interrupt-reuse-query-session", sessionId: "interrupt-reuse-query-session",
@@ -309,11 +311,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
return scriptedQuery; return scriptedQuery;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -328,7 +326,7 @@ test("reuses the existing query after interrupt before starting the next prompt"
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(queryFactory).toHaveBeenCalledTimes(1); expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([ expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([
"first prompt", "first prompt",
"second prompt", "second prompt",
@@ -344,7 +342,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
const logger = createTestLogger(); const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null; let queryRef: ScriptedQuery | null = null;
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({ queryRef = createScriptedQuery({
prompt, prompt,
sessionId: "claude-original-session", sessionId: "claude-original-session",
@@ -363,11 +361,7 @@ test("emits an assistant system notice when Claude changes session id mid-turn",
return queryRef; return queryRef;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -396,7 +390,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
const prompts: PromptRecord[] = []; const prompts: PromptRecord[] = [];
let throwAbortOnNext = false; let throwAbortOnNext = false;
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = { const scriptedQuery = {
next: vi.fn(async () => { next: vi.fn(async () => {
if (throwAbortOnNext) { if (throwAbortOnNext) {
@@ -461,11 +455,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
return scriptedQuery; return scriptedQuery;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -478,7 +468,7 @@ test("recovers when the query pump sees a single interrupt abort before the next
const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt"));
expect(queryFactory).toHaveBeenCalledTimes(1); expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]); expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
@@ -490,7 +480,7 @@ test("stale abort result after replacement start does not poison the new foregro
const logger = createTestLogger(); const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null; let queryRef: ScriptedQuery | null = null;
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({ queryRef = createScriptedQuery({
prompt, prompt,
sessionId: "interrupt-stale-result-session", sessionId: "interrupt-stale-result-session",
@@ -498,11 +488,7 @@ test("stale abort result after replacement start does not poison the new foregro
return queryRef; return queryRef;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -559,7 +545,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
const logger = createTestLogger(); const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null; let queryRef: ScriptedQuery | null = null;
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({ queryRef = createScriptedQuery({
prompt, prompt,
sessionId: "autonomous-live-session", sessionId: "autonomous-live-session",
@@ -578,11 +564,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo
return queryRef; return queryRef;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -624,7 +606,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
const logger = createTestLogger(); const logger = createTestLogger();
let queryRef: ScriptedQuery | null = null; let queryRef: ScriptedQuery | null = null;
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryRef = createScriptedQuery({ queryRef = createScriptedQuery({
prompt, prompt,
sessionId: "autonomous-handoff-session", sessionId: "autonomous-handoff-session",
@@ -652,11 +634,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
return queryRef; return queryRef;
}); });
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -699,7 +677,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a
(event) => event?.type === "turn_canceled", (event) => event?.type === "turn_canceled",
), ),
).toBe(false); ).toBe(false);
expect(queryFactory).toHaveBeenCalledTimes(1); expect(sdkMocks.query).toHaveBeenCalledTimes(1);
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([ expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([
"seed prompt", "seed prompt",
"foreground prompt", "foreground prompt",
@@ -1,10 +1,13 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function isTerminalEvent(event: AgentStreamEvent): boolean { function isTerminalEvent(event: AgentStreamEvent): boolean {
return ( return (
@@ -29,7 +32,7 @@ describe("Claude max effort availability (real)", () => {
let canRun = false; let canRun = false;
beforeAll(async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude"); canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
}); });
beforeEach((context) => { beforeEach((context) => {
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../../test-utils/class-mocks.js"; import { asInternals } from "../../test-utils/class-mocks.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./agent.js"; import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
interface QueryMock { interface QueryMock {
next: ReturnType<typeof vi.fn>; next: ReturnType<typeof vi.fn>;
@@ -66,7 +66,6 @@ async function createSession() {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: createTestLogger(), logger: createTestLogger(),
queryFactory: sdkQueryFactory, queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
return client.createSession({ return client.createSession({
provider: "claude", provider: "claude",
@@ -78,7 +77,6 @@ function createSessionWithLogger(logger: Logger) {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger, logger,
queryFactory: sdkQueryFactory, queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
return client.createSession({ return client.createSession({
provider: "claude", provider: "claude",
@@ -225,7 +223,6 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret, PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
}, },
}, },
resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
@@ -660,7 +657,7 @@ test("Grep tool_result string content flows to a search detail with content", as
grepEntry, grepEntry,
); );
const { mapClaudeCompletedToolCall } = await import("./tool-call-mapper.js"); const { mapClaudeCompletedToolCall } = await import("./claude/tool-call-mapper.js");
const item = mapClaudeCompletedToolCall({ const item = mapClaudeCompletedToolCall({
callId: "tool-grep-1", callId: "tool-grep-1",
name: "Grep", name: "Grep",
@@ -800,7 +797,6 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: loggerSpy.logger, logger: loggerSpy.logger,
queryFactory: sdkQueryFactory, queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
@@ -1,16 +1,16 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import type { import {
Options, query,
Query, type Options,
SpawnOptions as ClaudeSpawnOptions, type Query,
type SpawnOptions as ClaudeSpawnOptions,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import * as spawnUtils from "../../../../utils/spawn.js"; import * as spawnUtils from "../../../utils/spawn.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import type { ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query { function createQueryMock(events: unknown[]): Query {
let index = 0; let index = 0;
@@ -47,7 +47,7 @@ describe("Claude spawn override", () => {
test("bypasses the shell when spawning Claude Code", async () => { test("bypasses the shell when spawning Claude Code", async () => {
let capturedOptions: Options | undefined; let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
capturedOptions = options; capturedOptions = options;
return createQueryMock([ return createQueryMock([
{ {
@@ -77,7 +77,6 @@ describe("Claude spawn override", () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: createTestLogger(), logger: createTestLogger(),
queryFactory, queryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
@@ -1,13 +1,19 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentTimelineRow } from "../../agent-manager.js"; import type { AgentTimelineRow } from "../agent-manager.js";
import { projectTimelineRows } from "../../timeline-projection.js"; import { projectTimelineRows } from "../timeline-projection.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
const queryFactory = vi.fn(); const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
interface QueryMock { interface QueryMock {
next: ReturnType<typeof vi.fn>; next: ReturnType<typeof vi.fn>;
@@ -144,7 +150,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
beforeEach(() => { beforeEach(() => {
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50); const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
queryFactory.mockImplementation(() => sdkMocks.query.mockImplementation(() =>
buildQueryMock([ buildQueryMock([
{ {
type: "system", type: "system",
@@ -241,15 +247,11 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
}); });
afterEach(() => { afterEach(() => {
queryFactory.mockReset(); sdkMocks.query.mockReset();
}); });
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({ const session = await new ClaudeAgentClient({ logger }).createSession({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
}); });
@@ -296,13 +298,9 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
}); });
test("tails sub-agent actions instead of dropping latest entries at cap", async () => { test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({ const session = await new ClaudeAgentClient({ logger }).createSession({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
}); });
@@ -1,24 +1,19 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; 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 { import {
ClaudeAgentClient, ClaudeAgentClient,
convertClaudeHistoryEntry, convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionUpdatedInput, normalizeClaudeAskUserQuestionUpdatedInput,
} from "./agent.js"; } from "./claude-agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
interface TestClaudeSession { interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertUsage(message: SDKMessage): AgentUsage | undefined; convertUsage(message: SDKMessage): AgentUsage | undefined;
} }
afterEach(() => {
vi.restoreAllMocks();
});
describe("convertClaudeHistoryEntry", () => { describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => { test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test"; const toolUseId = "toolu_test";
@@ -351,7 +346,7 @@ describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger(); const logger = createTestLogger();
test("returns hardcoded claude models", async () => { test("returns hardcoded claude models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const client = new ClaudeAgentClient({ logger });
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
expect(models.map((m) => m.id)).toEqual([ expect(models.map((m) => m.id)).toEqual([
@@ -359,7 +354,6 @@ describe("ClaudeAgentClient.listModels", () => {
"claude-opus-4-7", "claude-opus-4-7",
"claude-opus-4-6[1m]", "claude-opus-4-6[1m]",
"claude-opus-4-6", "claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6", "claude-sonnet-4-6",
"claude-haiku-4-5", "claude-haiku-4-5",
]); ]);
@@ -374,59 +368,6 @@ 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", () => { describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => { test("maps frontend header-keyed answers to Claude question text keys", () => {
expect( expect(
@@ -488,10 +429,7 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
}); });
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => { test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger: createTestLogger() });
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -566,7 +504,7 @@ describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger(); const logger = createTestLogger();
async function createSessionForTest(): Promise<TestClaudeSession> { async function createSessionForTest(): Promise<TestClaudeSession> {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -666,7 +604,6 @@ describe("ClaudeAgentSession context window usage", () => {
const nonPersistedClient = new ClaudeAgentClient({ const nonPersistedClient = new ClaudeAgentClient({
logger, logger,
queryFactory: nonPersistedQueryFactory, queryFactory: nonPersistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const nonPersistedSession = await nonPersistedClient.createSession( const nonPersistedSession = await nonPersistedClient.createSession(
{ {
@@ -685,7 +622,6 @@ describe("ClaudeAgentSession context window usage", () => {
const persistedClient = new ClaudeAgentClient({ const persistedClient = new ClaudeAgentClient({
logger, logger,
queryFactory: persistedQueryFactory, queryFactory: persistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
}); });
const persistedSession = await persistedClient.createSession( const persistedSession = await persistedClient.createSession(
{ {
@@ -1043,11 +979,7 @@ describe("ClaudeAgentSession context window usage", () => {
}, },
], ],
]); ]);
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger, queryFactory });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ const session = await client.createSession({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
@@ -1,16 +1,20 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { promises } from "node:fs"; import { promises } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import {
query,
type AgentDefinition, type AgentDefinition,
type CanUseTool, type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig, type McpServerConfig as ClaudeSdkMcpServerConfig,
type Options,
type PermissionMode, type PermissionMode,
type PermissionResult, type PermissionResult,
type PermissionUpdate, type PermissionUpdate,
type Query, type Query,
type SpawnOptions,
type SDKMessage, type SDKMessage,
type SDKPartialAssistantMessage, type SDKPartialAssistantMessage,
type SDKTaskProgressMessage, type SDKTaskProgressMessage,
@@ -24,23 +28,22 @@ import {
mapClaudeCompletedToolCall, mapClaudeCompletedToolCall,
mapClaudeFailedToolCall, mapClaudeFailedToolCall,
mapClaudeRunningToolCall, mapClaudeRunningToolCall,
} from "./tool-call-mapper.js"; } from "./claude/tool-call-mapper.js";
import { import {
mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall, mapTaskNotificationUserContentToToolCall,
} from "./task-notification-tool-call.js"; } from "./claude/task-notification-tool-call.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./partial-json.js"; import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
import { import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "../diagnostic-utils.js"; } from "./diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import type { import type {
AgentPermissionAction, AgentPermissionAction,
@@ -70,19 +73,20 @@ import type {
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
McpServerConfig, McpServerConfig,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { import {
createProviderEnv, createProviderEnv,
createProviderEnvSpec, createProviderEnvSpec,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../../provider-launch-config.js"; } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js"; import { buildSelfNodeCommand } from "../../paseo-env.js";
import { withTimeout } from "../../../../utils/promise-timeout.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { execCommand } from "../../../../utils/spawn.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js"; import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises; const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = ["user", "project"]; const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
function readNonEmptyString(value: unknown): string | null { function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null; return typeof value === "string" && value.trim().length > 0 ? value : null;
@@ -151,6 +155,10 @@ function isUnknownArray(value: unknown): value is readonly unknown[] {
return Array.isArray(value); return Array.isArray(value);
} }
function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams {
return child.stdin !== null && child.stdout !== null && child.stderr !== null;
}
function isImageMimeType( function isImageMimeType(
value: string, value: string,
): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" { ): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
@@ -231,6 +239,7 @@ interface SlashCommandInvocation {
rawInput: string; rawInput: string;
} }
// Orchestrator instructions moved to shared module.
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
export interface ClaudeContentChunk { export interface ClaudeContentChunk {
@@ -238,12 +247,13 @@ export interface ClaudeContentChunk {
[key: string]: unknown; [key: string]: unknown;
} }
type ClaudeOptions = Options;
interface ClaudeAgentClientOptions { interface ClaudeAgentClientOptions {
defaults?: { agents?: Record<string, AgentDefinition> }; defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger; logger: Logger;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory; queryFactory?: typeof query;
resolveBinary?: () => Promise<string>;
} }
interface ClaudeAgentSessionOptions { interface ClaudeAgentSessionOptions {
@@ -253,8 +263,7 @@ interface ClaudeAgentSessionOptions {
launchEnv?: Record<string, string>; launchEnv?: Record<string, string>;
persistSession?: boolean; persistSession?: boolean;
logger: Logger; logger: Logger;
queryFactory?: ClaudeQueryFactory; queryFactory?: typeof query;
resolveBinary: () => Promise<string>;
} }
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
@@ -294,6 +303,86 @@ function extractSessionIdRaw(msg: {
return ""; 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 { function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return ( return (
value === "low" || value === "low" ||
@@ -1161,15 +1250,13 @@ export class ClaudeAgentClient implements AgentClient {
private readonly defaults?: { agents?: Record<string, AgentDefinition> }; private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory?: ClaudeQueryFactory; private readonly queryFactory: typeof query;
private readonly resolveBinary: () => Promise<string>;
constructor(options: ClaudeAgentClientOptions) { constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults; this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory; this.queryFactory = options.queryFactory ?? query;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
} }
async createSession( async createSession(
@@ -1185,7 +1272,6 @@ export class ClaudeAgentClient implements AgentClient {
persistSession: options?.persistSession, persistSession: options?.persistSession,
logger: this.logger, logger: this.logger,
queryFactory: this.queryFactory, queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
}); });
} }
@@ -1212,7 +1298,6 @@ export class ClaudeAgentClient implements AgentClient {
launchEnv: launchContext?.env, launchEnv: launchContext?.env,
logger: this.logger, logger: this.logger,
queryFactory: this.queryFactory, queryFactory: this.queryFactory,
resolveBinary: this.resolveBinary,
}); });
} }
@@ -1244,7 +1329,9 @@ export class ClaudeAgentClient implements AgentClient {
if (command?.mode === "replace") { if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]); return await isCommandAvailable(command.argv[0]);
} }
return await isCommandAvailable("claude"); // Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
// via process.execPath. No external `claude` binary is required.
return true;
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -1296,24 +1383,6 @@ 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( async function resolveClaudeVersion(
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> { ): Promise<string | null> {
@@ -1481,8 +1550,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean; private readonly persistSession?: boolean;
private readonly logger: Logger; private readonly logger: Logger;
private readonly queryFactory?: ClaudeQueryFactory; private readonly queryFactory: typeof query;
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null; private query: Query | null = null;
private input: AsyncMessageInput<SDKUserMessage> | null = null; private input: AsyncMessageInput<SDKUserMessage> | null = null;
private claudeSessionId: string | null; private claudeSessionId: string | null;
@@ -1530,8 +1598,7 @@ class ClaudeAgentSession implements AgentSession {
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession; this.persistSession = options.persistSession;
this.logger = options.logger; this.logger = options.logger;
this.queryFactory = options.queryFactory; this.queryFactory = options.queryFactory ?? query;
this.resolveBinary = options.resolveBinary;
const handle = options.handle; const handle = options.handle;
if (handle) { if (handle) {
@@ -2151,14 +2218,7 @@ class ClaudeAgentSession implements AgentSession {
const options = await this.buildOptions(); const options = await this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input; this.input = input;
this.query = claudeQuery( this.query = this.queryFactory({ prompt: input.iterable, options });
{ prompt: input.iterable, options },
{
runtimeSettings: this.runtimeSettings,
launchEnv: this.launchEnv,
queryFactory: this.queryFactory,
},
);
// Do not kick off background control-plane queries here. Methods like // Do not kick off background control-plane queries here. Methods like
// supportedCommands()/setPermissionMode() may execute immediately after // supportedCommands()/setPermissionMode() may execute immediately after
// ensureQuery() (for listCommands()/setMode()), and sharing the same query // ensureQuery() (for listCommands()/setMode()), and sharing the same query
@@ -2196,6 +2256,11 @@ class ClaudeAgentSession implements AgentSession {
? this.config.thinkingOptionId ? this.config.thinkingOptionId
: undefined; : undefined;
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { 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: { type: "adaptive" }, effort: thinkingOptionId };
} }
return { thinking: undefined, effort: undefined }; return { thinking: undefined, effort: undefined };
@@ -2225,7 +2290,7 @@ class ClaudeAgentSession implements AgentSession {
], ],
}); });
const claudeBinary = await this.resolveBinary(); const claudeBinary = await findExecutable("claude");
this.logger.debug( this.logger.debug(
{ {
claudeBinary, claudeBinary,
@@ -2246,7 +2311,7 @@ class ClaudeAgentSession implements AgentSession {
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents, agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest, canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary, ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
// Use Claude Code preset system prompt and load CLAUDE.md files // Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions for agents. // Append provider-agnostic system prompt and orchestrator instructions for agents.
systemPrompt: { systemPrompt: {
@@ -2288,7 +2353,11 @@ class ClaudeAgentSession implements AgentSession {
...this.runtimeSettings.disallowedTools, ...this.runtimeSettings.disallowedTools,
]; ];
} }
return base; return this.applyRuntimeSettings(base);
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
} }
private normalizeMcpServers( private normalizeMcpServers(
@@ -3,13 +3,19 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentPersistenceHandle, AgentStreamEvent } from "../agent-sdk-types.js";
const queryFactory = vi.fn(); const sdkMocks = vi.hoisted(() => ({
let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null; query: vi.fn(),
lastQuery: null as ReturnType<typeof buildSdkQueryMock> | null,
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
@@ -93,9 +99,9 @@ describe("ClaudeAgentSession history replay regression", () => {
let previousClaudeConfigDir: string | undefined; let previousClaudeConfigDir: string | undefined;
beforeEach(() => { beforeEach(() => {
queryFactory.mockImplementation(() => { sdkMocks.query.mockImplementation(() => {
const mock = buildSdkQueryMock(); const mock = buildSdkQueryMock();
lastQuery = mock; sdkMocks.lastQuery = mock;
return mock; return mock;
}); });
@@ -139,8 +145,8 @@ describe("ClaudeAgentSession history replay regression", () => {
}); });
afterEach(() => { afterEach(() => {
queryFactory.mockReset(); sdkMocks.query.mockReset();
lastQuery = null; sdkMocks.lastQuery = null;
if (previousClaudeConfigDir === undefined) { if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR; delete process.env.CLAUDE_CONFIG_DIR;
} else { } else {
@@ -151,11 +157,7 @@ describe("ClaudeAgentSession history replay regression", () => {
test("does not replay persisted history during the first live stream turn", async () => { test("does not replay persisted history during the first live stream turn", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = { const handle: AgentPersistenceHandle = {
provider: "claude", provider: "claude",
sessionId: "history-session", sessionId: "history-session",
@@ -192,11 +194,7 @@ describe("ClaudeAgentSession history replay regression", () => {
test("still exposes persisted history through streamHistory", async () => { test("still exposes persisted history through streamHistory", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = { const handle: AgentPersistenceHandle = {
provider: "claude", provider: "claude",
sessionId: "history-session", sessionId: "history-session",
@@ -225,11 +223,7 @@ describe("ClaudeAgentSession history replay regression", () => {
test("listCommands includes rewind command", async () => { test("listCommands includes rewind command", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = { const handle: AgentPersistenceHandle = {
provider: "claude", provider: "claude",
sessionId: "history-session", sessionId: "history-session",
@@ -251,11 +245,7 @@ describe("ClaudeAgentSession history replay regression", () => {
test("slash /rewind uses latest user message id from persisted history", async () => { test("slash /rewind uses latest user message id from persisted history", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({ logger });
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle: AgentPersistenceHandle = { const handle: AgentPersistenceHandle = {
provider: "claude", provider: "claude",
sessionId: "history-session", sessionId: "history-session",
@@ -286,9 +276,9 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(events.some((event) => event.type === "turn_started")).toBe(true); expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(events.some((event) => event.type === "turn_completed")).toBe(true); expect(events.some((event) => event.type === "turn_completed")).toBe(true);
expect(lastQuery).toBeTruthy(); expect(sdkMocks.lastQuery).toBeTruthy();
expect(lastQuery?.rewindFiles).toHaveBeenCalledTimes(1); expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledTimes(1);
expect(lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", { expect(sdkMocks.lastQuery?.rewindFiles).toHaveBeenCalledWith("history-user-uuid", {
dryRun: false, dryRun: false,
}); });
}); });
@@ -1,14 +1,12 @@
/** /**
* Direct SDK behavior tests - uses same setup as the Claude provider * Direct SDK behavior tests - uses same setup as claude-agent.ts
*/ */
import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { findExecutable } from "../../../../utils/executable.js";
import { claudeQuery } from "./query.js";
class Pushable<T> implements AsyncIterable<T> { class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = []; private queue: T[] = [];
@@ -55,17 +53,6 @@ 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 { function extractTextFromEvents(events: SDKMessage[]): string {
let responseText = ""; let responseText = "";
for (const event of events) { for (const event of events) {
@@ -85,15 +72,21 @@ function extractTextFromEvents(events: SDKMessage[]): string {
return responseText; return responseText;
} }
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => { describe("Claude SDK direct behavior", () => {
let canRun = false; let canRunClaudeIntegration = false;
beforeAll(async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude"); canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(await isCommandAvailable("claude")).toBe(true);
}
}); });
beforeEach((context) => { beforeEach((context) => {
if (!canRun) { if (!canRunClaudeIntegration) {
context.skip(); context.skip();
} }
}); });
@@ -103,8 +96,8 @@ describe("Claude SDK direct behavior", () => {
const input = new Pushable<SDKUserMessage>(); const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude"); const claudeBinary = await findExecutable("claude");
// Use same options as the Claude provider // Use same options as claude-agent.ts
const q = claudeQuery({ const q = query({
prompt: input, prompt: input,
options: { options: {
cwd, cwd,
@@ -167,7 +160,7 @@ describe("Claude SDK direct behavior", () => {
expect(sawResult || responseText.length === 0).toBe(true); expect(sawResult || responseText.length === 0).toBe(true);
} finally { } finally {
input.end(); input.end();
rmCwd(cwd); rmSync(cwd, { recursive: true, force: true });
} }
}, 120000); }, 120000);
}); });
@@ -1,471 +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 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);
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
describe("getClaudeModels", () => { describe("getClaudeModels", () => {
it("returns all claude models", () => { it("returns all claude models", () => {
@@ -10,7 +10,6 @@ describe("getClaudeModels", () => {
"claude-opus-4-7", "claude-opus-4-7",
"claude-opus-4-6[1m]", "claude-opus-4-6[1m]",
"claude-opus-4-6", "claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6", "claude-sonnet-4-6",
"claude-haiku-4-5", "claude-haiku-4-5",
]); ]);
@@ -45,13 +45,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
isDefault: true, isDefault: true,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS], 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", provider: "claude",
id: "claude-sonnet-4-6", id: "claude-sonnet-4-6",
@@ -1,120 +0,0 @@
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,
),
});
}
@@ -1205,7 +1205,7 @@ describe("Codex app-server provider", () => {
expect(event.item.text).not.toContain("data:image"); expect(event.item.text).not.toContain("data:image");
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64); expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
const source = markdownImageSource(event.item.text); const source = markdownImageSource(event.item.text);
expect(source).toMatch(/paseo-attachments[\\/].+\.png$/); expect(source).toMatch(/paseo-attachments\/.+\.png$/);
expect(existsSync(source)).toBe(true); expect(existsSync(source)).toBe(true);
rmSync(source, { force: true }); rmSync(source, { force: true });
}); });
@@ -49,7 +49,7 @@ import {
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { withTimeout } from "../../../utils/promise-timeout.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
@@ -1228,20 +1228,6 @@ export class OpenCodeAgentClient implements AgentClient {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`; 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) { if (available) {
try { try {
const models = await this.listModels({ cwd: homedir(), force: false }); const models = await this.listModels({ cwd: homedir(), force: false });
@@ -1277,7 +1263,6 @@ export class OpenCodeAgentClient implements AgentClient {
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
}, },
{ label: "Server", value: serverStatus }, { label: "Server", value: serverStatus },
{ label: "Auth", value: authValue },
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
]), ]),
@@ -1,71 +0,0 @@
// 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);
});
});
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest"; 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 { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.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 { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
@@ -31,6 +31,19 @@ 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(() => { afterEach(() => {
process.env.PATH = originalEnv.PATH; process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT; process.env.PATHEXT = originalEnv.PATHEXT;
@@ -48,12 +61,12 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false); await expect(client.isAvailable()).resolves.toBe(false);
}); });
test("Claude reports unavailable when the default command cannot be resolved", async () => { test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
const binDir = makeTempDir("provider-availability-claude-"); const binDir = makeTempDir("provider-availability-claude-");
isolatePathTo(binDir); isolatePathTo(binDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.isAvailable()).resolves.toBe(false); await expect(client.isAvailable()).resolves.toBe(true);
}); });
test("OpenCode reports unavailable when the default command cannot be resolved", async () => { test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
@@ -64,6 +77,24 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false); 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 () => { test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-"); const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir); isolatePathTo(binDir);
@@ -8,7 +8,6 @@ import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./
import { generateLocalPairingOffer } from "./pairing-offer.js"; import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
describe("paseo daemon bootstrap", () => { describe("paseo daemon bootstrap", () => {
afterEach(() => { afterEach(() => {
@@ -153,10 +152,7 @@ describe("paseo daemon bootstrap", () => {
}); });
}); });
// POSIX-only: Unix socket listen paths are invalid Windows listen targets. test("generates a relay pairing offer for unix socket listeners", async () => {
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 paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo"); const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -203,6 +199,5 @@ describe("paseo daemon bootstrap", () => {
await rm(paseoHomeRoot, { recursive: true, force: true }); await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true }); await rm(staticDir, { recursive: true, force: true });
} }
}, });
);
}); });
@@ -110,9 +110,10 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
const availability = (async (): Promise<boolean> => { const availability = (async (): Promise<boolean> => {
switch (provider) { switch (provider) {
case "claude": case "claude":
const hasClaudeEnvCredentials = return (
Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY); (await isCommandAvailable("claude")) &&
return (await isCommandAvailable("claude")) && (!process.env.CI || hasClaudeEnvCredentials); (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "codex": case "codex":
return ( return (
(await isCommandAvailable("codex")) && (await isCommandAvailable("codex")) &&
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; 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 { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
@@ -4,7 +4,7 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { WebSocket } from "ws"; 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 { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
@@ -5,7 +5,7 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; 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 { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js"; import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
@@ -6,7 +6,6 @@ import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/i
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js"; import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.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 { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js"; import type { SessionOutboundMessage } from "../messages.js";
@@ -221,10 +220,7 @@ test("returns error for non-git directory", async () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout }, 60000); // 1 minute timeout
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization. test("returns repo info for git repo with branch and dirty state", async () => {
test.skipIf(isPlatform("win32"))(
"returns repo info for git repo with branch and dirty state",
async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
// Initialize git repo // Initialize git repo
@@ -271,9 +267,7 @@ test.skipIf(isPlatform("win32"))(
// Cleanup // Cleanup
await ctx.client.deleteAgent(agent.id); await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, }, 60000); // 1 minute timeout
60000,
); // 1 minute timeout
test("returns clean state when no uncommitted changes", async () => { test("returns clean state when no uncommitted changes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.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"; import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string { function tmpCwd(): string {
@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.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"; import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string { function tmpCwd(): string {
@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string { function tmpCwd(): string {
@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
import { createMessageCollector } from "../test-utils/message-collector.js"; import { createMessageCollector } from "../test-utils/message-collector.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
@@ -6,7 +6,7 @@ import pino from "pino";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.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 { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js"; import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
@@ -5,7 +5,7 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import type { AgentClient } from "../agent/agent-sdk-types.js"; 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 { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -1,58 +0,0 @@
// 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 });
}
});
});
@@ -1,8 +1,8 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readExplorerFile } from "./service.js"; import { listDirectoryEntries, readExplorerFile } from "./service.js";
async function createHomeTempDir(prefix: string): Promise<string> { async function createHomeTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.homedir(), prefix)); return mkdtemp(path.join(os.homedir(), prefix));
@@ -13,6 +13,29 @@ async function createTempDir(prefix: string): Promise<string> {
} }
describe("file explorer service", () => { 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 () => { it("reads .ex files as text", async () => {
const root = await createTempDir("paseo-file-explorer-"); const root = await createTempDir("paseo-file-explorer-");
@@ -112,4 +135,25 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true }); 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 });
}
});
}); });
+1 -1
View File
@@ -102,7 +102,7 @@ describe("resolveLogConfig", () => {
}, },
file: { file: {
level: "debug", level: "debug",
path: path.resolve(paseoHome, "logs", "programmatic.log"), path: path.join(paseoHome, "logs", "programmatic.log"),
}, },
}); });
}); });
@@ -1,14 +1,6 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { beforeEach, afterEach, describe, expect, test } from "vitest"; import { beforeEach, afterEach, describe, expect, test } from "vitest";
import type { import type {
@@ -32,7 +24,6 @@ import type {
import { AgentStorage } from "./agent/agent-storage.js"; import { AgentStorage } from "./agent/agent-storage.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { LoopService } from "./loop-service.js"; import { LoopService } from "./loop-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { createTestLogger } from "../test-utils/test-logger.js"; import { createTestLogger } from "../test-utils/test-logger.js";
const TEST_CAPABILITIES: AgentCapabilityFlags = { const TEST_CAPABILITIES: AgentCapabilityFlags = {
@@ -229,25 +220,19 @@ describe("LoopService", () => {
let storage: AgentStorage; let storage: AgentStorage;
beforeEach(() => { beforeEach(() => {
tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-"))); tmpDir = mkdtempSync(path.join(os.tmpdir(), "loop-service-"));
paseoHome = path.join(tmpDir, "paseo-home"); paseoHome = path.join(tmpDir, "paseo-home");
workspaceDir = path.join(tmpDir, "workspace"); workspaceDir = path.join(tmpDir, "workspace");
storage = new AgentStorage(path.join(tmpDir, "agents"), logger); storage = new AgentStorage(path.join(tmpDir, "agents"), logger);
mkdirSync(workspaceDir, { recursive: true }); mkdirSync(workspaceDir, { recursive: true });
workspaceDir = realpathSync.native(workspaceDir);
}); });
afterEach(() => { afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
// 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("runs fresh worker agents until verify-check passes", async () => {
test.skipIf(isPlatform("win32"))(
"runs fresh worker agents until verify-check passes",
async () => {
const state = { workerRuns: 0 }; const state = { workerRuns: 0 };
const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs");
writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n');
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
claude: new ScriptedAgentClient("claude", { claude: new ScriptedAgentClient("claude", {
@@ -272,9 +257,7 @@ describe("LoopService", () => {
const loop = await service.runLoop({ const loop = await service.runLoop({
prompt: "Create done.txt when the task is actually fixed.", prompt: "Create done.txt when the task is actually fixed.",
cwd: workspaceDir, cwd: workspaceDir,
verifyChecks: [ verifyChecks: ["test -f done.txt"],
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`,
],
sleepMs: 1, sleepMs: 1,
maxIterations: 3, maxIterations: 3,
}); });
@@ -284,16 +267,13 @@ describe("LoopService", () => {
const finalLoop = await service.inspectLoop(loop.id); const finalLoop = await service.inspectLoop(loop.id);
expect(finalLoop.status).toBe("succeeded"); expect(finalLoop.status).toBe("succeeded");
expect(finalLoop.iterations).toHaveLength(2); expect(finalLoop.iterations).toHaveLength(2);
expect(finalLoop.iterations[0]?.workerAgentId).not.toBe( expect(finalLoop.iterations[0]?.workerAgentId).not.toBe(finalLoop.iterations[1]?.workerAgentId);
finalLoop.iterations[1]?.workerAgentId,
);
expect(finalLoop.iterations[0]?.status).toBe("failed"); expect(finalLoop.iterations[0]?.status).toBe("failed");
expect(finalLoop.iterations[1]?.status).toBe("succeeded"); expect(finalLoop.iterations[1]?.status).toBe("succeeded");
expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false);
expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true);
expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id);
}, });
);
test("uses worker and verifier provider-model settings when provided", async () => { test("uses worker and verifier provider-model settings when provided", async () => {
const workerConfigs: AgentSessionConfig[] = []; const workerConfigs: AgentSessionConfig[] = [];
@@ -1,4 +1,4 @@
import { execFileSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -13,7 +13,6 @@ import {
type CreatePaseoWorktreeDeps, type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { isPlatform } from "../test-utils/platform.js";
const cleanupPaths: string[] = []; const cleanupPaths: string[] = [];
@@ -66,10 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou
]); ]);
}); });
// POSIX-only: Windows git worktree paths need separate canonicalization coverage. test("reuses an existing worktree and still upserts the workspace", async () => {
test.skipIf(isPlatform("win32"))(
"reuses an existing worktree and still upserts the workspace",
async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo"); const paseoHome = path.join(tempDir, ".paseo");
@@ -103,8 +99,7 @@ test.skipIf(isPlatform("win32"))(
expect(second.created).toBe(false); expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`); expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
}, });
);
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
@@ -136,7 +131,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
generateBranchNameFromContext: async ({ firstAgentContext }) => generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.prompt ? "renamed-from-agent-context" : null, firstAgentContext.prompt ? "renamed-from-agent-context" : null,
}); });
const branchAfterFirst = execFileSync("git", ["branch", "--show-current"], { const branchAfterFirst = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -162,7 +157,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
firstAgentContext: { prompt: "Try another name" }, firstAgentContext: { prompt: "Try another name" },
generateBranchNameFromContext: async () => "second-agent-name", generateBranchNameFromContext: async () => "second-agent-name",
}); });
const branchAfterSecond = execFileSync("git", ["branch", "--show-current"], { const branchAfterSecond = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -201,7 +196,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
: null, : null,
}); });
const branchAfter = execFileSync("git", ["branch", "--show-current"], { const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -258,7 +253,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
: null, : null,
}); });
const branchAfter = execFileSync("git", ["branch", "--show-current"], { const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -291,7 +286,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
).resolves.toEqual({ attempted: true, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
expect( expect(
execFileSync("git", ["branch", "--show-current"], { execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -310,11 +305,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 () => { test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
execFileSync("git", ["checkout", "-b", "dev"], { cwd: repoDir, stdio: "pipe" }); execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n"); writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "dev"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree( const created = await createPaseoWorktree(
{ {
@@ -339,7 +334,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect( expect(
execFileSync("git", ["branch", "--show-current"], { execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -375,7 +370,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect( expect(
execFileSync("git", ["branch", "--show-current"], { execSync("git branch --show-current", {
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
stdio: "pipe", stdio: "pipe",
}) })
@@ -529,24 +524,17 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
} }
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe" }) const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
.toString() .toString()
.trim(); .trim();
const mainRepoRoot = execFileSync( const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
"git",
["rev-parse", "--path-format=absolute", "--git-common-dir"],
{
cwd,
stdio: "pipe",
},
)
.toString()
.trim()
.replace(/\/\.git$/, "");
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
cwd, cwd,
stdio: "pipe", stdio: "pipe",
}) })
.toString()
.trim()
.replace(/\/\.git$/, "");
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
.toString() .toString()
.trim(); .trim();
@@ -578,39 +566,34 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
function createGitRepo(): { tempDir: string; repoDir: string } { function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
execFileSync("git", ["init", repoDir], { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], { execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" }); execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "pr branch\n"); writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "pr-branch"], { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
.toString() execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
.trim(); execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
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"); const remoteDir = path.join(tempDir, "remote.git");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe", stdio: "pipe",
}); });
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/123/head", prHead], { execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
stdio: "pipe", stdio: "pipe",
}); });
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" }); execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["fetch", "origin"], { cwd: repoDir, stdio: "pipe" }); execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -22,25 +22,16 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-health-monitor-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true }); execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config user.name 'Test'", { 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"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -12,25 +12,16 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-branch-handler-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true }); execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config user.name 'Test'", { 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"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process"; import { execSync } from "node:child_process";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { import {
buildWorkspaceScriptPayloads, buildWorkspaceScriptPayloads,
@@ -21,25 +21,16 @@ function createWorkspaceRepo(options?: {
}): { tempDir: string; repoDir: string; cleanup: () => void } { }): { tempDir: string; repoDir: string; cleanup: () => void } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true }); execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execFileSync("git", ["init", "-b", options?.branchName ?? "main"], { execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
cwd: repoDir, execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
stdio: "pipe", execSync("git config user.name 'Test'", { 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"); writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) { if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2)); writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
} }
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
cwd: repoDir,
stdio: "pipe",
});
return { return {
tempDir, tempDir,
+3 -11
View File
@@ -47,7 +47,6 @@ import {
asDaemonConfigStore, asDaemonConfigStore,
createProviderSnapshotManagerStub, createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js"; } from "./test-utils/session-stubs.js";
import { isPlatform } from "../test-utils/platform.js";
interface SessionHandlerInternals { interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>; startVoiceTurnController(): Promise<void>;
@@ -706,15 +705,9 @@ describe("project config RPC authorization", () => {
]); ]);
}); });
// POSIX-only: creates a directory symlink without Windows privileges. test("read_project_config_request accepts a symlink to an active project root", async () => {
test.skipIf(isPlatform("win32"))(
"read_project_config_request accepts a symlink to an active project root",
async () => {
const repoRoot = makeRoot(); const repoRoot = makeRoot();
writeFileSync( writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: "npm ci" } }),
);
const linkRoot = join(makeRoot(), "link"); const linkRoot = join(makeRoot(), "link");
symlinkSync(repoRoot, linkRoot, "dir"); symlinkSync(repoRoot, linkRoot, "dir");
const messages: unknown[] = []; const messages: unknown[] = [];
@@ -744,8 +737,7 @@ describe("project config RPC authorization", () => {
}, },
}, },
]); ]);
}, });
);
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => { test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
const archivedRoot = makeRoot(); const archivedRoot = makeRoot();
+9 -2
View File
@@ -3007,11 +3007,18 @@ export class Session {
if (!resolvedWorkspace) { if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`); throw new Error(`Workspace not found: ${msg.workspaceId}`);
} }
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { const snapshot = await this.agentManager.createAgent(
{
...sessionConfig,
cwd: resolvedWorkspace.cwd,
},
undefined,
{
labels, labels,
workspaceId: resolvedWorkspace.workspaceId, workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
}); },
);
await this.forwardAgentUpdate(snapshot); await this.forwardAgentUpdate(snapshot);
await this.sendInitialCreateAgentPrompt({ await this.sendInitialCreateAgentPrompt({

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