Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8d1ba7e3f3 Fix Windows packaged CLI TTY prompts 2026-05-07 14:44:44 +07:00
53 changed files with 638 additions and 1904 deletions
-51
View File
@@ -1,56 +1,5 @@
# Changelog
## 0.1.70-beta.1 - 2026-05-07
### 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.
- 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
- **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
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -38547,7 +38547,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38673,10 +38673,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.70-beta.1",
"@getpaseo/server": "0.1.69",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38719,7 +38719,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38768,7 +38768,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38804,7 +38804,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38830,7 +38830,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38845,12 +38845,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.70-beta.1",
"@getpaseo/relay": "0.1.70-beta.1",
"@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",
@@ -39304,7 +39304,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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");
@@ -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),
);
}
@@ -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],
},
}));
@@ -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"));
}
@@ -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);
});
@@ -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,
};
}
@@ -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,
@@ -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,
+10 -13
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}>
@@ -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;
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});
@@ -1,13 +0,0 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.70-beta.1",
"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-beta.1",
"@getpaseo/server": "0.1.69",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+5 -1
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%
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+44
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" };
@@ -121,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(
@@ -147,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
appPath: appOutDir,
});
}
exports.createWindowsCliExecutable = createWindowsCliExecutable;
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
@@ -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(),
};
}
@@ -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 });
}
});
});
+12 -5
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,
@@ -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", () => {
@@ -1 +0,0 @@
export { getCliInstallStatus, installCli } from "./install.js";
@@ -1,71 +0,0 @@
import { promises as fs } from "node:fs";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./path.js";
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
import { ensurePathInShellRc } from "./shell-rc.js";
interface InstallStatus {
installed: boolean;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
@@ -1,31 +0,0 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
export function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
@@ -1,97 +0,0 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import log from "electron-log/main";
export interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
export function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
@@ -0,0 +1,318 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
import { syncSkills } from "./skill-sync.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
function getSkillSyncTargets(): {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
} {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
export async function installSkills(): Promise<InstallStatus> {
const targets = getSkillSyncTargets();
log.info("[integrations] installSkills", targets);
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill install failed", { skillName, error });
},
});
log.info("[integrations] installSkills done", result);
return getSkillsInstallStatus();
}
export async function autoUpdateSkillsIfInstalled(): Promise<{
ran: boolean;
changedFiles: number;
processedSkills: number;
}> {
const targets = getSkillSyncTargets();
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
try {
await fs.access(installedMarker);
} catch {
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
try {
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill auto-update failed", { skillName, error });
},
});
if (result.changedFiles > 0) {
log.info("[integrations] auto-updated paseo skills", result);
} else {
log.info("[integrations] paseo skills already up to date", result);
}
return { ran: true, ...result };
} catch (error) {
log.warn("[integrations] auto-update skills aborted", { error });
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
const accessResults = await Promise.all(
SKILL_NAMES.map((skillName) =>
fs
.access(path.join(claudeDir, skillName, "SKILL.md"))
.then(() => true)
.catch(() => false),
),
);
return { installed: accessResults.every(Boolean) };
}
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import os from "node:os";
import 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();
});
});
@@ -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);
}
}
@@ -1,10 +0,0 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";
@@ -1,302 +0,0 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
isPackaged: false,
},
}));
import {
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
interface Sandbox {
root: string;
targets: SkillTargets;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
await fs.mkdir(targets.sourceDir, { recursive: true });
return { root, targets };
}
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(rootDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(sourceDir, name), files);
}
async function writeOnDiskSkill(
agentsDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(agentsDir, name), files);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
}
async function pathExists(p: string): Promise<boolean> {
return fs
.access(p)
.then(() => true)
.catch(() => false);
}
describe("getSkillsStatus", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
});
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
});
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("installSkills / updateSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
const status = await installSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
for (const name of ["paseo", "paseo-loop"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
}
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const first = await installSkills(sandbox.targets);
const second = await installSkills(sandbox.targets);
expect(first).toEqual({ state: "up-to-date", ops: [] });
expect(second).toEqual({ state: "up-to-date", ops: [] });
});
});
describe("uninstallSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await installSkills(sandbox.targets);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const name of PASEO_SKILL_NAMES) {
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
}
for (const name of ["unslop", "tdd", "devbox"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(`user-${name}`);
}
});
it("is a no-op when nothing is installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
});
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
});
@@ -1,164 +0,0 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export const PASEO_SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-chat",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
function resolveSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
const stat = await fs.stat(skillDir).catch(() => null);
if (!stat?.isDirectory()) return null;
const rels = await listFilesRecursive(skillDir);
const files: SkillFiles = new Map();
for (const rel of rels) {
const buf = await fs.readFile(path.join(skillDir, rel));
const sha = createHash("sha256").update(buf).digest("hex");
files.set(toPosix(rel), sha);
}
return files;
}
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
const out = new Map<string, SkillFiles>();
for (const name of PASEO_SKILL_NAMES) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
}
return true;
}
function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
if (disk.size === 0) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
.map((op) => op.name);
if (writes.length > 0) {
await syncSkills({
sourceDir: targets.sourceDir,
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
skillNames: writes,
});
}
for (const op of status.ops) {
if (op.kind !== "delete") continue;
await removeSkill(op.name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets);
}
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
await removeSkill(name, {
agentsDir: t.agentsDir,
claudeDir: t.claudeDir,
codexDir: t.codexDir,
});
}
return getSkillsStatus(t);
}
@@ -1,22 +0,0 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
}
export function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
export function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
export function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
+5
View File
@@ -48,6 +48,7 @@ import {
createBeforeQuitHandler,
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 () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.70-beta.1",
"@getpaseo/relay": "0.1.70-beta.1",
"@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",
@@ -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(
@@ -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;
@@ -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",
]);
@@ -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",
@@ -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 },
]),
+12 -5
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({
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { expect, test, vi } from "vitest";
@@ -10,18 +10,6 @@ import type {
EditorTargetDescriptorPayload,
SessionOutboundMessage,
} from "../shared/messages.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import type {
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import {
@@ -42,8 +30,6 @@ import {
findByType,
} from "./test-utils/session-stubs.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
@@ -288,101 +274,6 @@ function createWorkspaceRuntimeSnapshot(
};
}
const CREATE_AGENT_TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
class CreateAgentTestSession implements AgentSession {
readonly provider = "codex";
readonly id = "create-agent-test-session";
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
constructor(private readonly config: AgentSessionConfig) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(): Promise<{ turnId: string }> {
return { turnId: "turn-1" };
}
subscribe(): () => void {
return () => {};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class CreateAgentTestClient implements AgentClient {
readonly provider = "codex";
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
_options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
return new CreateAgentTestSession(config);
}
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new CreateAgentTestSession({
provider: this.provider,
cwd: overrides?.cwd ?? process.cwd(),
});
}
async listModels() {
return [{ provider: this.provider, id: "gpt-test", label: "GPT Test", isDefault: true }];
}
async isAvailable(): Promise<boolean> {
return true;
}
}
function createSessionForWorkspaceTests(
options: {
appVersion?: string | null;
@@ -470,135 +361,6 @@ function createSessionForWorkspaceTests(
return session;
}
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
try {
const parent = path.join(workdir, "parent");
const child = path.join(parent, "child");
mkdirSync(child, { recursive: true });
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
const agentManager = new AgentManager({
clients: { codex: new CreateAgentTestClient() },
registry: agentStorage,
logger: asSessionLogger(logger),
idFactory: () => "00000000-0000-4000-8000-000000000551",
});
const projectRegistry = new FileBackedProjectRegistry(
path.join(workdir, "projects.json"),
asSessionLogger(logger),
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(workdir, "workspaces.json"),
asSessionLogger(logger),
);
const workspaceGitService = createNoopWorkspaceGitService({
getCheckout: async (cwd: string) => ({
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: parent,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
});
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: "proj-parent",
rootPath: parent,
kind: "git",
displayName: "parent",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "ws-parent",
projectId: "proj-parent",
cwd: parent,
kind: "local_checkout",
displayName: "parent",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
new Session({
clientId: "test-client",
appVersion: null,
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
}),
workspaceGitService,
daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
}),
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
}),
);
await session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child",
config: { provider: "codex", cwd: child },
attachments: [],
});
const [createdAgent] = agentManager.listAgents();
expect(createdAgent?.cwd).toBe(child);
await expect(session.buildProjectPlacementForCwd(createdAgent.cwd)).resolves.toMatchObject({
projectKey: "proj-parent",
checkout: { cwd: parent },
});
expect(findByType(emitted, "status")?.payload).toMatchObject({
status: "agent_created",
agent: { cwd: child },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
const storedRecord = {
@@ -1,6 +1,5 @@
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -12,13 +11,11 @@ import {
import {
getCheckoutDiff as getCheckoutDiffUncached,
getCheckoutStatus as getCheckoutStatusUncached,
resolveAbsoluteGitDir as resolveAbsoluteGitDirReal,
type CheckoutDiffCompare,
type CheckoutDiffResult,
type CheckoutStatusGit,
type PullRequestStatusResult,
} from "../utils/checkout-git.js";
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
import {
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
@@ -210,7 +207,6 @@ interface CreateServiceOptions {
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
now?: () => Date;
}
@@ -1458,48 +1454,4 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose();
});
test("Linux working tree walker excludes gitignored directories", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-")));
const repoDir = join(tempDir, "repo");
mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true });
mkdirSync(join(repoDir, "kept"), { recursive: true });
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
writeFileSync(join(repoDir, ".gitignore"), "ignored/\n");
writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n");
writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n");
writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n");
const watchedPaths: string[] = [];
const watchSpy = (watchPath: string) => {
watchedPaths.push(watchPath);
return { close: vi.fn(), on: vi.fn().mockReturnThis() };
};
const service = createService({
watch: watchSpy as never,
readdir: readdir as never,
runGitCommand: runGitCommandReal as never,
getCheckoutStatus: getCheckoutStatusUncached as never,
resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never,
});
try {
const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn());
const ignoredRoot = join(repoDir, "ignored");
expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]);
expect(watchedPaths).toContain(repoDir);
expect(watchedPaths).toContain(join(repoDir, "kept"));
subscription.unsubscribe();
} finally {
service.dispose();
rmSync(tempDir, { recursive: true, force: true });
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
});
@@ -40,9 +40,6 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
const LINUX_WATCH_MAX_DIRS = 5_000;
const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000;
const LINUX_WATCH_IGNORE_TTL_MS = 5 * 60 * 1_000;
const linuxWatchReaddirConcurrency =
parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16;
@@ -326,7 +323,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
private readonly linuxIgnoredDirsCache = new Map<string, { ignored: Set<string>; ts: number }>();
private readonly branchValidationCache = new Map<
string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
@@ -1199,9 +1195,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to refresh Linux working tree watchers",
);
}
if (target.linuxTreeRefreshQueued) {
await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS));
}
} while (target.linuxTreeRefreshQueued);
})();
@@ -1213,17 +1206,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
const ignored = await this.loadLinuxIgnoredDirs(rootPath);
const directories: string[] = [];
let currentLevel: string[] = [rootPath];
let capped = false;
while (currentLevel.length > 0) {
directories.push(...currentLevel);
if (directories.length >= LINUX_WATCH_MAX_DIRS) {
capped = true;
break;
}
const readResults = await Promise.all(
currentLevel.map((directory) =>
linuxWatchReaddirLimit(async () => {
@@ -1244,59 +1231,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!entry.isDirectory() || entry.name === ".git") {
continue;
}
const childPath = join(directory, entry.name);
if (ignored.has(childPath)) {
continue;
}
nextLevel.push(childPath);
nextLevel.push(join(directory, entry.name));
}
}
currentLevel = nextLevel;
}
if (capped) {
this.logger.warn(
{ rootPath, limit: LINUX_WATCH_MAX_DIRS, walked: directories.length },
"Linux working tree exceeds watcher cap; skipping deeper directories",
);
}
return directories;
}
private async loadLinuxIgnoredDirs(rootPath: string): Promise<Set<string>> {
const cached = this.linuxIgnoredDirsCache.get(rootPath);
if (cached && Date.now() - cached.ts < LINUX_WATCH_IGNORE_TTL_MS) {
return cached.ignored;
}
const ignored = new Set<string>();
try {
const result = await this.deps.runGitCommand(
["ls-files", "-o", "-i", "--directory", "--exclude-standard"],
{ cwd: rootPath, env: READ_ONLY_GIT_ENV },
);
for (const raw of result.stdout.split("\n")) {
if (!raw.endsWith("/")) {
continue;
}
const rel = raw.replace(/\/+$/, "");
if (!rel) {
continue;
}
ignored.add(resolve(rootPath, rel));
}
} catch (error) {
this.logger.debug(
{ err: error, rootPath },
"Failed to load gitignore directories; falling back to name-based skip only",
);
}
this.linuxIgnoredDirsCache.set(rootPath, { ignored, ts: Date.now() });
return ignored;
}
private async refreshWorkspaceTarget(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
@@ -1644,9 +1587,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.watchers = [];
target.watchedPaths.clear();
target.listeners.clear();
if (target.repoWatchPath) {
this.linuxIgnoredDirsCache.delete(target.repoWatchPath);
}
}
private closeRepoTarget(target: RepoGitTarget): void {
@@ -1698,44 +1698,6 @@ describe("GitHubService", () => {
]);
});
it("treats a GitHub issue or PR URL as a search for that number", async () => {
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await service.searchIssuesAndPrs({
cwd: "/repo",
query: "https://github.com/getpaseo/paseo/pull/793",
limit: 5,
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"issue",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,updatedAt",
"--limit",
"5",
],
[
"pr",
"list",
"--search",
"793",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
"--limit",
"5",
],
]);
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
const runner = createRunner([searchPullRequestJson("PR title")]);
const service = createGitHubService({
+2 -12
View File
@@ -933,12 +933,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const readOptions: GitHubReadOptions = input.force
? { force: true, reason: input.reason }
: { force: false, reason: input.reason };
const query = normalizeGitHubSearchQuery(input.query);
const [issuesResult, prsResult] = await Promise.allSettled([
shouldFetchIssues
? this.listIssues({
cwd: input.cwd,
query,
query: input.query,
limit: input.limit,
...readOptions,
})
@@ -946,7 +945,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
shouldFetchPullRequests
? this.listPullRequests({
cwd: input.cwd,
query,
query: input.query,
limit: input.limit,
...readOptions,
})
@@ -1172,15 +1171,6 @@ async function runGhCommand(
});
}
const GITHUB_ISSUE_OR_PR_URL_PATTERN =
/^https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)(?:[/?#].*)?$/i;
function normalizeGitHubSearchQuery(query: string): string {
const trimmed = query.trim();
const match = trimmed.match(GITHUB_ISSUE_OR_PR_URL_PATTERN);
return match ? match[1] : query;
}
function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.70-beta.1",
"version": "0.1.69",
"private": true,
"type": "module",
"scripts": {