Compare commits

..

1 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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ There are two supported ways to ship from `main`:
Before running any stable patch release command:
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
- **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.
```bash

View File

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

1697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
Alert,
@@ -745,19 +745,16 @@ function SettingsSidebar({
) : null}
<View style={sidebarStyles.list}>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
<SidebarSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedSectionId === item.id}
onSelect={onSelectSection}
/>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>

View File

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

View File

@@ -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 },
]);
});
});

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -4,12 +4,16 @@ setlocal
set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1
)
if not exist "%CLI_EXECUTABLE%" (
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
)
set "ELECTRON_RUN_AS_NODE=1"
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%

View File

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

View File

@@ -4,6 +4,9 @@ const path = require("path");
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
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
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
@@ -53,22 +56,10 @@ function pruneOnnxRuntime(nodeModules, platform, arch) {
function pruneClaudeAgentSdk(nodeModules, platform, arch) {
const vendorRoot = path.join(nodeModules, "@anthropic-ai", "claude-agent-sdk", "vendor");
const keepName = RIPGREP_PLATFORM_DIR[platform]?.[arch];
if (keepName) {
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
if (!keepName) return;
// 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));
}
}
}
pruneChildrenExcept(path.join(vendorRoot, "ripgrep"), new Set(["COPYING", keepName]));
pruneChildrenExcept(path.join(vendorRoot, "tree-sitter-bash"), new Set([keepName]));
}
function pruneNodePty(nodeModules, platform, arch) {
@@ -133,12 +124,50 @@ function fmtMB(bytes) {
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) {
const platform = context.electronPlatformName;
const arch = ARCH_MAP[context.arch] || process.arch;
pruneNativeModules(context.appOutDir, platform, arch);
if (platform === "win32") {
createWindowsCliExecutable(context.appOutDir);
}
if (platform === "linux" || platform === "win32") {
if (arch !== process.arch) {
console.log(
@@ -159,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
appPath: appOutDir,
});
}
exports.createWindowsCliExecutable = createWindowsCliExecutable;
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;

View File

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

View File

@@ -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 { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const require = createRequire(import.meta.url);
describe("desktop packaging", () => {
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("*");
}
});
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 });
}
});
});

View File

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

View File

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

View File

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

View File

@@ -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) };
}

View File

@@ -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);
}

View File

@@ -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 };
}
}

View File

@@ -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) };
}

View File

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

View File

@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
claudeDir: string;
codexDir: string;
skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void;
}
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
return true;
}
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
return changed;
}
export interface RemoveSkillTargets {
agentsDir: string;
claudeDir: string;
codexDir: string;
}
async function ensureClaudeSkillLink(
skillName: string,
agentsDir: string,
claudeDir: string,
platform: NodeJS.Platform,
): Promise<number> {
await fs.mkdir(claudeDir, { recursive: true });
const target = path.join(agentsDir, skillName);
const linkPath = path.join(claudeDir, skillName);
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
const paths = [
path.join(targets.agentsDir, skillName),
path.join(targets.claudeDir, skillName),
path.join(targets.codexDir, skillName),
];
for (const p of paths) {
await fs.rm(p, { recursive: true, force: true });
// 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);
}
}
await fs.symlink(target, linkPath);
return 0;
}
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0;
let processedSkills = 0;
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
path.join(options.agentsDir, skillName),
);
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.claudeDir, skillName),
changedFiles += await ensureClaudeSkillLink(
skillName,
options.agentsDir,
options.claudeDir,
platform,
);
changedFiles += await syncDirectoryFiles(
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
processedSkills++;
} catch (error) {
if (!options.onSkillError) throw error;
options.onSkillError(skillName, error);
options.onSkillError?.(skillName, error);
}
}

View File

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

View File

@@ -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);
}
});
});

View File

@@ -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);
}

View File

@@ -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");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,9 @@
*
* 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 { mkdtempSync, rmSync } from "node:fs";
@@ -15,8 +17,8 @@ import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { ClaudeAgentClient } from "./agent.js";
import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js";
// ---------------------------------------------------------------------------
// Helpers
@@ -24,6 +26,8 @@ import { ClaudeAgentClient } from "./agent.js";
const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
@@ -70,14 +74,7 @@ async function createSession(params?: {
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;
}
}
rmSync(handle.cwd, { recursive: true, force: true });
}
async function startTurnAndCollectEvents(
@@ -199,7 +196,7 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {

View File

@@ -1,6 +1,4 @@
import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type {
PermissionOption,
PromptResponse,
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
interface ACPSessionInternals {
sessionId: string | null;
@@ -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(
category: "mode" | "model" | "thought_level",
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", () => {
test("maps ACP usage fields into Paseo usage", () => {
expect(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as executableUtils from "../../../../utils/executable.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
ClaudeAgentClient,
convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionUpdatedInput,
} from "./agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js";
} from "./claude-agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertUsage(message: SDKMessage): AgentUsage | undefined;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test";
@@ -351,7 +346,7 @@ describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger();
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 });
expect(models.map((m) => m.id)).toEqual([
@@ -359,7 +354,6 @@ describe("ClaudeAgentClient.listModels", () => {
"claude-opus-4-7",
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]);
@@ -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", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => {
expect(
@@ -488,10 +429,7 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
});
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
});
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
@@ -566,7 +504,7 @@ describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
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({
provider: "claude",
cwd: process.cwd(),
@@ -666,7 +604,6 @@ describe("ClaudeAgentSession context window usage", () => {
const nonPersistedClient = new ClaudeAgentClient({
logger,
queryFactory: nonPersistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const nonPersistedSession = await nonPersistedClient.createSession(
{
@@ -685,7 +622,6 @@ describe("ClaudeAgentSession context window usage", () => {
const persistedClient = new ClaudeAgentClient({
logger,
queryFactory: persistedQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const persistedSession = await persistedClient.createSession(
{
@@ -1043,11 +979,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
],
]);
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const client = new ClaudeAgentClient({ logger, queryFactory });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),

View File

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

View File

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

View File

@@ -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 { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isProviderAvailable } from "../../../daemon-e2e/agent-configs.js";
import { findExecutable } from "../../../../utils/executable.js";
import { claudeQuery } from "./query.js";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<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 {
let responseText = "";
for (const event of events) {
@@ -85,15 +72,21 @@ function extractTextFromEvents(events: SDKMessage[]): string {
return responseText;
}
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => {
let canRun = false;
let canRunClaudeIntegration = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(await isCommandAvailable("claude")).toBe(true);
}
});
beforeEach((context) => {
if (!canRun) {
if (!canRunClaudeIntegration) {
context.skip();
}
});
@@ -103,8 +96,8 @@ describe("Claude SDK direct behavior", () => {
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
// Use same options as the Claude provider
const q = claudeQuery({
// Use same options as claude-agent.ts
const q = query({
prompt: input,
options: {
cwd,
@@ -167,7 +160,7 @@ describe("Claude SDK direct behavior", () => {
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmCwd(cwd);
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
});

View File

@@ -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);
});

View File

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

View File

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

View File

@@ -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,
),
});
}

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
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";
@@ -8,7 +8,7 @@ import type { AgentProvider } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude/agent.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
@@ -31,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(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
@@ -48,12 +61,12 @@ describe("default provider availability", () => {
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-");
isolatePathTo(binDir);
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 () => {
@@ -64,6 +77,24 @@ describe("default provider availability", () => {
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Codex reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
writeProviderShim(binDir, "codex");
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("OpenCode reports available when the default command resolves from PATH", async () => {
const binDir = makeTempDir("provider-availability-opencode-");
isolatePathTo(binDir);
writeProviderShim(binDir, "opencode");
const client = new OpenCodeAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(true);
});
test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });
}
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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