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 # 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 ## 0.1.69 - 2026-05-05
### Fixed ### Fixed
+13 -13
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.70-beta.1", "version": "0.1.69",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.70-beta.1", "version": "0.1.69",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -38547,7 +38547,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -38673,10 +38673,10 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.70-beta.1", "@getpaseo/server": "0.1.69",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -38719,7 +38719,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.70-beta.1", "version": "0.1.69",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -38768,7 +38768,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.70-beta.1", "version": "0.1.69",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
@@ -38804,7 +38804,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -38830,7 +38830,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -38845,12 +38845,12 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.70-beta.1", "@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.70-beta.1", "@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
@@ -39304,7 +39304,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.70-beta.1", "version": "0.1.69",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.70-beta.1", "version": "0.1.69",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [ "keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.70-beta.1", "version": "0.1.69",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -67,22 +67,6 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true); expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
}); });
it("matches across label, provider, and description with multi-token fuzzy search", () => {
const row = {
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
provider: "opencode",
providerLabel: "OpenCode",
modelId: "opencode-zen/kimi-k2.5",
modelLabel: "Kimi K2.5",
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
});
it("keeps the selected trigger label model-only", () => { it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
@@ -48,10 +48,7 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
return true; return true;
} }
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""] return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
.join(" ") value.toLowerCase().includes(normalizedQuery),
.toLowerCase(); );
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
return tokens.every((token) => haystack.includes(token));
} }
@@ -7,34 +7,12 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() { export function IntegrationsSection() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -47,18 +25,16 @@ export function IntegrationsSection() {
} = useCliInstall(); } = useCliInstall();
const { const {
status: skillsStatus, status: skillsStatus,
isWorking: isSkillsWorking, isInstalling: isInstallingSkills,
install: installSkills, install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
refresh: refreshSkillsStatus, refresh: refreshSkillsStatus,
} = useSkillsStatus(); } = useSkillsInstall();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showSection) return undefined; if (!showSection) return undefined;
refreshCliStatus(); refreshCliStatus();
void refreshSkillsStatus(); refreshSkillsStatus();
return undefined; return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]), }, [refreshCliStatus, refreshSkillsStatus, showSection]),
); );
@@ -69,33 +45,9 @@ export function IntegrationsSection() {
}, [installCli, isInstallingCli]); }, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => { const handleInstallSkills = useCallback(() => {
if (isSkillsWorking) return; if (isInstallingSkills) return;
void installSkills(); installSkills();
}, [installSkills, isSkillsWorking]); }, [installSkills, isInstallingSkills]);
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: "Uninstall Paseo skills?",
message: UNINSTALL_MESSAGE,
confirmLabel: "Uninstall",
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => { const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL); void openExternalUrl(CLI_DOCS_URL);
@@ -144,8 +96,6 @@ export function IntegrationsSection() {
return null; return null;
} }
const skillsState = skillsStatus?.state ?? null;
return ( return (
<SettingsSection title="Integrations" trailing={trailing}> <SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
@@ -180,69 +130,30 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text> <Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View> </View>
<Text style={settingsStyles.rowHint}> <Text style={settingsStyles.rowHint}>
{skillsState === "drift" Teach your agents to orchestrate through the CLI
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
</Text> </Text>
</View> </View>
<SkillsActions {skillsStatus?.installed ? (
state={skillsState} <View style={styles.installedLabel}>
isWorking={isSkillsWorking} <Check size={14} color={theme.colors.foregroundMuted} />
onInstall={handleInstallSkills} <Text style={styles.mutedText}>Installed</Text>
onUpdate={handleUpdateSkills} </View>
onUninstall={handleUninstallSkills} ) : (
/> <Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
</View> </View>
</View> </View>
</SettingsSection> </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) => ({ const styles = StyleSheet.create((theme) => ({
headerLinks: { headerLinks: {
flexDirection: "row", flexDirection: "row",
@@ -263,9 +174,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
})); }));
@@ -220,67 +220,10 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli")); return parseInstallStatus(await invokeDesktopCommand("install_cli"));
} }
export type SkillsState = "not-installed" | "up-to-date" | "drift"; export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
} }
function parseSkillsState(value: unknown): SkillsState { export async function installSkills(): Promise<InstallStatus> {
switch (value) { return parseInstallStatus(await invokeDesktopCommand("install_skills"));
case "not-installed":
case "up-to-date":
case "drift":
return value;
default:
throw new Error(`Unexpected skills status state: ${String(value)}`);
}
}
function parseSkillOp(raw: unknown): SkillOp {
if (!isRecord(raw)) {
throw new Error("Unexpected skill op response.");
}
const name = toStringOrNull(raw.name);
if (!name) throw new Error("Skill op missing name.");
switch (raw.kind) {
case "add":
return { kind: "add", name };
case "update":
return { kind: "update", name };
case "delete":
return { kind: "delete", name };
default:
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
}
}
function parseSkillsStatus(raw: unknown): SkillsStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
return { state: parseSkillsState(raw.state), ops };
}
export async function getSkillsStatus(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
}
export async function installSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
} }
@@ -5,7 +5,7 @@ import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCliInstall, useSkillsStatus } from "./use-install-status"; import { useCliInstall, useSkillsInstall } from "./use-install-status";
const toast = vi.hoisted(() => ({ const toast = vi.hoisted(() => ({
error: vi.fn(), error: vi.fn(),
@@ -15,11 +15,9 @@ const toast = vi.hoisted(() => ({
const desktopDaemon = vi.hoisted(() => ({ const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(), getCliInstallStatus: vi.fn(),
getSkillsInstallStatus: vi.fn(),
installCli: vi.fn(), installCli: vi.fn(),
getSkillsStatus: vi.fn(),
installSkills: vi.fn(), installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true), shouldUseDesktopDaemon: vi.fn(() => true),
})); }));
@@ -91,9 +89,11 @@ describe("useCliInstall", () => {
}); });
}); });
describe("useSkillsStatus", () => { describe("useSkillsInstall", () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
}); });
afterEach(() => { afterEach(() => {
@@ -101,155 +101,34 @@ describe("useSkillsStatus", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("loads the current skills status", async () => { it("loads skills install status", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ const { result } = renderDesktopHook(() => useSkillsInstall());
state: "up-to-date",
ops: [],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => { await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); expect(result.current.status).toEqual({ installed: true });
}); });
expect(result.current.isWorking).toBe(false);
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
}); });
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => { it("toasts and exposes skills install errors", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
await act(async () => {
await result.current.install();
});
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("update transitions drift to up-to-date", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "drift",
ops: [{ kind: "update", name: "paseo" }],
});
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("drift");
});
await act(async () => {
await result.current.update();
});
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
});
it("uninstall transitions up-to-date back to not-installed", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
desktopDaemon.uninstallSkills.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("up-to-date");
});
await act(async () => {
await result.current.uninstall();
});
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
await waitFor(() => {
expect(result.current.status).toEqual({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
});
});
it("isWorking flips while a mutation is in flight", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
let resolveInstall: ((value: unknown) => void) | null = null;
desktopDaemon.installSkills.mockImplementation(
() =>
new Promise((resolve) => {
resolveInstall = resolve;
}),
);
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed");
});
expect(result.current.isWorking).toBe(false);
let installPromise: Promise<void> = Promise.resolve();
act(() => {
installPromise = result.current.install();
});
await waitFor(() => {
expect(result.current.isWorking).toBe(true);
});
await act(async () => {
resolveInstall?.({ state: "up-to-date", ops: [] });
await installPromise;
});
await waitFor(() => {
expect(result.current.isWorking).toBe(false);
});
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
});
it("toasts and exposes errors when install fails", async () => {
const error = new Error("Missing IPC handler"); const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsStatus.mockResolvedValue({ desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
desktopDaemon.installSkills.mockRejectedValue(error); desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsInstall());
const { result } = renderDesktopHook(() => useSkillsStatus());
await waitFor(() => { await waitFor(() => {
expect(result.current.status?.state).toBe("not-installed"); expect(result.current.status).toEqual({ installed: false });
}); });
await act(async () => { act(() => {
await result.current.install(); result.current.install();
}); });
await waitFor(() => { await waitFor(() => {
expect(result.current.error).toBe(error); expect(result.current.error).toBe(error);
}); });
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills."); expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error); expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
}); });
@@ -2,14 +2,11 @@ import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
getCliInstallStatus, getCliInstallStatus,
getSkillsStatus, getSkillsInstallStatus,
installCli, installCli,
installSkills, installSkills,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
type InstallStatus, type InstallStatus,
type SkillsStatus,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { import {
useDesktopIpcErrorReporter, useDesktopIpcErrorReporter,
@@ -17,7 +14,11 @@ import {
} from "@/desktop/hooks/desktop-ipc-error"; } from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const; const SKILLS_INSTALL_STATUS_QUERY_KEY = [
"desktop",
"integrations",
"skills-install-status",
] as const;
interface DesktopInstallHookResult { interface DesktopInstallHookResult {
status: InstallStatus | null; status: InstallStatus | null;
@@ -76,43 +77,25 @@ export function useCliInstall(): DesktopInstallHookResult {
}; };
} }
export interface SkillsStatusHookResult { export function useSkillsInstall(): DesktopInstallHookResult {
status: SkillsStatus | null;
isLoading: boolean;
isWorking: boolean;
error: Error | null;
refresh: () => Promise<void>;
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter(); const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<SkillsStatus, Error>({ const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY, queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsStatus, queryFn: getSkillsInstallStatus,
enabled, enabled,
retry: false, retry: false,
}); });
const { data: status, error: statusError, isLoading, refetch } = statusQuery; const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({ useDesktopIpcQueryErrorToast({
error: statusQuery.error, error: statusQuery.error,
message: "Unable to check orchestration skills status.", message: "Unable to check orchestration skills install status.",
logLabel: "[Integrations] Failed to load skills status", logLabel: "[Integrations] Failed to load skills status",
}); });
const setStatus = useCallback( const installMutation = useMutation<InstallStatus, Error>({
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
mutationFn: installSkills, mutationFn: installSkills,
onError: (error) => { onError: (error) => {
reportError({ reportError({
@@ -121,65 +104,23 @@ export function useSkillsStatus(): SkillsStatusHookResult {
logLabel: "[Integrations] Failed to install skills", logLabel: "[Integrations] Failed to install skills",
}); });
}, },
onSuccess: setStatus, onSuccess: (nextStatus) => {
}); queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
const updateMutation = useMutation<SkillsStatus, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
logLabel: "[Integrations] Failed to update skills",
});
}, },
onSuccess: setStatus,
}); });
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const uninstallMutation = useMutation<SkillsStatus, Error>({ const refresh = useCallback(() => {
mutationFn: uninstallSkills, void refetch();
onError: (error) => {
reportError({
error,
message: "Unable to uninstall orchestration skills.",
logLabel: "[Integrations] Failed to uninstall skills",
});
},
onSuccess: setStatus,
});
const isWorking =
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
const refresh = useCallback(async () => {
await refetch();
}, [refetch]); }, [refetch]);
const install = useCallback(async () => {
await installMutation.mutateAsync().catch(() => undefined);
}, [installMutation]);
const update = useCallback(async () => {
await updateMutation.mutateAsync().catch(() => undefined);
}, [updateMutation]);
const uninstall = useCallback(async () => {
await uninstallMutation.mutateAsync().catch(() => undefined);
}, [uninstallMutation]);
return { return {
status: status ?? null, status: installStatus ?? null,
isLoading, isLoading,
isWorking, isInstalling,
error: error: statusError ?? installError ?? null,
statusError ??
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
null,
refresh,
install, install,
update, refresh,
uninstall,
}; };
} }
@@ -184,6 +184,21 @@ describe("UpdateCalloutSource", () => {
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull(); expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
}); });
it("shows only the changelog action once the update is installed", async () => {
updaterState.value = {
...updaterState.value,
status: "installed",
availableUpdate: null,
};
await renderHarness(root!);
expect(container?.textContent).toContain("Update installed");
expect(
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
).toContain("What's new");
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
});
it("disables the install action and shows Installing... while installing", async () => { it("disables the install action and shows Installing... while installing", async () => {
updaterState.value = { updaterState.value = {
...updaterState.value, ...updaterState.value,
@@ -13,18 +13,25 @@ import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000; const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog"; const CHANGELOG_URL = "https://paseo.sh/changelog";
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string { function resolveUpdateCalloutTitle(args: {
isInstalled: boolean;
isInstalling: boolean;
isError: boolean;
}): string {
if (args.isInstalled) return "Update installed";
if (args.isInstalling) return "Installing update"; if (args.isInstalling) return "Installing update";
if (args.isError) return "Update failed"; if (args.isError) return "Update failed";
return "Update available"; return "Update available";
} }
function resolveUpdateCalloutDescription(args: { function resolveUpdateCalloutDescription(args: {
isInstalled: boolean;
isInstalling: boolean; isInstalling: boolean;
isError: boolean; isError: boolean;
errorMessage: string | null; errorMessage: string | null;
latestVersion: string | undefined; latestVersion: string | undefined;
}): ReactNode { }): ReactNode {
if (args.isInstalled) return "Restart to use the new version.";
if (args.isInstalling) return "Installing and restarting..."; if (args.isInstalling) return "Installing and restarting...";
if (args.isError) return args.errorMessage ?? "Something went wrong."; if (args.isError) return args.errorMessage ?? "Something went wrong.";
if (args.latestVersion) { if (args.latestVersion) {
@@ -36,6 +43,7 @@ function resolveUpdateCalloutDescription(args: {
} }
function buildUpdateCalloutActions(args: { function buildUpdateCalloutActions(args: {
isInstalled: boolean;
isInstalling: boolean; isInstalling: boolean;
isError: boolean; isError: boolean;
openChangelog: () => void; openChangelog: () => void;
@@ -45,7 +53,7 @@ function buildUpdateCalloutActions(args: {
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }]; const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
if (args.isError) { if (args.isError) {
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" }); actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
} else { } else if (!args.isInstalled) {
actions.push({ actions.push({
label: args.isInstalling ? "Installing..." : "Install & restart", label: args.isInstalling ? "Installing..." : "Install & restart",
onPress: args.install, onPress: args.install,
@@ -99,21 +107,29 @@ export function UpdateCalloutSource() {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
} }
if (status !== "available" && status !== "installing" && status !== "error") { if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
) {
return; return;
} }
const isInstalled = status === "installed";
const isError = status === "error"; const isError = status === "error";
const isAvailable = !isInstalling && !isError; const isAvailable = !isInstalled && !isInstalling && !isError;
const title = resolveUpdateCalloutTitle({ isInstalling, isError }); const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
const description = resolveUpdateCalloutDescription({ const description = resolveUpdateCalloutDescription({
isInstalled,
isInstalling, isInstalling,
isError, isError,
errorMessage, errorMessage,
latestVersion: availableUpdate?.latestVersion ?? undefined, latestVersion: availableUpdate?.latestVersion ?? undefined,
}); });
const actions = buildUpdateCalloutActions({ const actions = buildUpdateCalloutActions({
isInstalled,
isInstalling, isInstalling,
isError, isError,
openChangelog, openChangelog,
+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 type { ComponentType, ReactNode } from "react";
import { import {
Alert, Alert,
@@ -745,19 +745,16 @@ function SettingsSidebar({
) : null} ) : null}
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
{items.map((item) => ( {items.map((item) => (
<Fragment key={item.id}> <SidebarSectionButton
<SidebarSectionButton key={item.id}
itemId={item.id} itemId={item.id}
label={item.label} label={item.label}
icon={item.icon} icon={item.icon}
isSelected={selectedSectionId === item.id} isSelected={selectedSectionId === item.id}
onSelect={onSelectSection} onSelect={onSelectSection}
/> />
{item.id === "general" ? (
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
) : null}
</Fragment>
))} ))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View> </View>
<SidebarSeparator /> <SidebarSeparator />
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
@@ -20,7 +20,6 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url"; import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
@@ -28,7 +27,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
interface WorkspaceOpenInEditorButtonProps { interface WorkspaceOpenInEditorButtonProps {
serverId: string; serverId: string;
@@ -40,7 +38,6 @@ interface OpenTarget {
id: string; id: string;
label: string; label: string;
icon: ReactElement; icon: ReactElement;
requiresLocalDaemon: boolean;
onOpen: () => Promise<void> | void; onOpen: () => Promise<void> | void;
} }
@@ -85,16 +82,14 @@ export function WorkspaceOpenInEditorButton({
const toast = useToast(); const toast = useToast();
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const shouldQueryWorkspace = const shouldLoadTargets =
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd); isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({ const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
queryKey: ["available-editors", serverId], queryKey: ["available-editors", serverId],
enabled: shouldLoadEditorTargets, enabled: shouldLoadTargets,
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
queryFn: async () => { queryFn: async () => {
@@ -117,7 +112,7 @@ export function WorkspaceOpenInEditorButton({
const { status: checkoutStatus } = useCheckoutStatusQuery({ const { status: checkoutStatus } = useCheckoutStatusQuery({
serverId, serverId,
cwd: shouldQueryWorkspace ? cwd : "", cwd: shouldLoadTargets ? cwd : "",
}); });
const editorTargets = useMemo<OpenTarget[]>( const editorTargets = useMemo<OpenTarget[]>(
@@ -126,7 +121,6 @@ export function WorkspaceOpenInEditorButton({
id: editor.id, id: editor.id,
label: editor.label, label: editor.label,
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />, icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: true,
onOpen: async () => { onOpen: async () => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
@@ -155,20 +149,13 @@ export function WorkspaceOpenInEditorButton({
id: "github", id: "github",
label: "GitHub", label: "GitHub",
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
requiresLocalDaemon: false,
onOpen: () => openExternalUrl(url), onOpen: () => openExternalUrl(url),
}; };
}, [checkoutStatus]); }, [checkoutStatus]);
const targets = useMemo( const targets = useMemo(
() => () => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
filterTargetsForDaemonLocation( [editorTargets, githubTarget],
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
{
isLocalDaemon,
},
),
[editorTargets, githubTarget, isLocalDaemon],
); );
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]); const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
@@ -223,7 +210,7 @@ export function WorkspaceOpenInEditorButton({
} }
}, [primaryOption, handleOpenTarget]); }, [primaryOption, handleOpenTarget]);
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) { if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
return null; return null;
} }
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
describe("filterTargetsForDaemonLocation", () => {
const targets = [
{ id: "cursor", requiresLocalDaemon: true },
{ id: "vscode", requiresLocalDaemon: true },
{ id: "github", requiresLocalDaemon: false },
];
it("keeps local app targets and URL targets for the local daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
});
it("hides local app targets for a remote daemon", () => {
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
{ id: "github", requiresLocalDaemon: false },
]);
});
it("preserves target order after filtering", () => {
expect(
filterTargetsForDaemonLocation(
[
{ id: "github", requiresLocalDaemon: false },
{ id: "finder", requiresLocalDaemon: true },
{ id: "docs", requiresLocalDaemon: false },
],
{ isLocalDaemon: false },
),
).toEqual([
{ id: "github", requiresLocalDaemon: false },
{ id: "docs", requiresLocalDaemon: false },
]);
});
});
@@ -1,13 +0,0 @@
export interface WorkspaceOpenTargetAvailability {
requiresLocalDaemon: boolean;
}
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
targets: readonly Target[],
input: { isLocalDaemon: boolean },
): Target[] {
if (input.isLocalDaemon) {
return [...targets];
}
return targets.filter((target) => !target.requiresLocalDaemon);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.70-beta.1", "version": "0.1.69",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -24,7 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.70-beta.1", "@getpaseo/server": "0.1.69",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+5 -1
View File
@@ -4,12 +4,16 @@ setlocal
set "SCRIPT_DIR=%~dp0" set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.." set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe" set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
set "CLI_EXECUTABLE=%RESOURCES_DIR%\..\PaseoCli.exe"
if not exist "%APP_EXECUTABLE%" ( if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1 exit /b 1
) )
if not exist "%CLI_EXECUTABLE%" (
set "CLI_EXECUTABLE=%APP_EXECUTABLE%"
)
set "ELECTRON_RUN_AS_NODE=1" set "ELECTRON_RUN_AS_NODE=1"
set "PASEO_NODE_ENV=production" set "PASEO_NODE_ENV=production"
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* "%CLI_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel% exit /b %errorlevel%
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.70-beta.1", "version": "0.1.69",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+44
View File
@@ -4,6 +4,9 @@ const path = require("path");
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
const EXECUTABLE_NAME = "Paseo"; const EXECUTABLE_NAME = "Paseo";
const WINDOWS_CLI_EXECUTABLE_NAME = "PaseoCli";
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
const PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER = 0x5c;
// electron-builder arch enum → Node.js arch string // electron-builder arch enum → Node.js arch string
const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" }; const ARCH_MAP = { 0: "ia32", 1: "x64", 2: "armv7l", 3: "arm64", 4: "universal" };
@@ -121,12 +124,50 @@ function fmtMB(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
} }
function setWindowsExecutableSubsystem(filePath, subsystem) {
const fd = fs.openSync(filePath, "r+");
try {
const dosHeader = Buffer.alloc(64);
fs.readSync(fd, dosHeader, 0, dosHeader.length, 0);
if (dosHeader.toString("ascii", 0, 2) !== "MZ") {
throw new Error(`Invalid Windows executable DOS header: ${filePath}`);
}
const peHeaderOffset = dosHeader.readUInt32LE(0x3c);
const peSignature = Buffer.alloc(4);
fs.readSync(fd, peSignature, 0, peSignature.length, peHeaderOffset);
if (peSignature.toString("ascii") !== "PE\u0000\u0000") {
throw new Error(`Invalid Windows executable PE header: ${filePath}`);
}
const subsystemOffset = peHeaderOffset + PE_SUBSYSTEM_OFFSET_FROM_PE_HEADER;
const subsystemBuffer = Buffer.alloc(2);
subsystemBuffer.writeUInt16LE(subsystem);
fs.writeSync(fd, subsystemBuffer, 0, subsystemBuffer.length, subsystemOffset);
} finally {
fs.closeSync(fd);
}
}
function createWindowsCliExecutable(appOutDir) {
const appExecutable = path.join(appOutDir, `${EXECUTABLE_NAME}.exe`);
const cliExecutable = path.join(appOutDir, `${WINDOWS_CLI_EXECUTABLE_NAME}.exe`);
fs.copyFileSync(appExecutable, cliExecutable);
setWindowsExecutableSubsystem(cliExecutable, IMAGE_SUBSYSTEM_WINDOWS_CUI);
console.log(`Created Windows CLI executable: ${cliExecutable}`);
}
exports.default = async function afterPack(context) { exports.default = async function afterPack(context) {
const platform = context.electronPlatformName; const platform = context.electronPlatformName;
const arch = ARCH_MAP[context.arch] || process.arch; const arch = ARCH_MAP[context.arch] || process.arch;
pruneNativeModules(context.appOutDir, platform, arch); pruneNativeModules(context.appOutDir, platform, arch);
if (platform === "win32") {
createWindowsCliExecutable(context.appOutDir);
}
if (platform === "linux" || platform === "win32") { if (platform === "linux" || platform === "win32") {
if (arch !== process.arch) { if (arch !== process.arch) {
console.log( console.log(
@@ -147,3 +188,6 @@ async function smokeUnpackedAppIfRequested(appOutDir) {
appPath: appOutDir, appPath: appOutDir,
}); });
} }
exports.createWindowsCliExecutable = createWindowsCliExecutable;
exports.setWindowsExecutableSubsystem = setWindowsExecutableSubsystem;
@@ -17,13 +17,12 @@ import {
downloadAndInstallUpdate, downloadAndInstallUpdate,
type AppReleaseChannel, type AppReleaseChannel,
} from "../features/auto-updater.js"; } from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
import { import {
getSkillsStatus, installCli,
getCliInstallStatus,
installSkills, installSkills,
uninstallSkills, getSkillsInstallStatus,
updateSkills, } from "../integrations/integrations-manager.js";
} from "../integrations/skills/index.js";
import { import {
openLocalTransportSession, openLocalTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
@@ -532,10 +531,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
get_local_daemon_version: () => getLocalDaemonVersion(), get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(), install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(), get_cli_install_status: () => getCliInstallStatus(),
get_skills_status: () => getSkillsStatus(),
install_skills: () => installSkills(), install_skills: () => installSkills(),
update_skills: () => updateSkills(), get_skills_install_status: () => getSkillsInstallStatus(),
uninstall_skills: () => uninstallSkills(),
}; };
} }
@@ -1,9 +1,12 @@
import { readFileSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const require = createRequire(import.meta.url);
describe("desktop packaging", () => { describe("desktop packaging", () => {
it("unpacks server zsh shell integration files for external shells", () => { it("unpacks server zsh shell integration files for external shells", () => {
@@ -33,4 +36,38 @@ describe("desktop packaging", () => {
expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
} }
}); });
it("uses a console-subsystem executable for the bundled Windows CLI shim", () => {
const cmd = readFileSync(join(packageRoot, "bin", "paseo.cmd"), "utf8");
expect(cmd).toContain("PaseoCli.exe");
expect(cmd).toContain('"%CLI_EXECUTABLE%"');
});
it("can mark the Windows CLI executable as console subsystem", () => {
const { setWindowsExecutableSubsystem } = require(
join(packageRoot, "scripts", "after-pack.js"),
) as {
setWindowsExecutableSubsystem: (filePath: string, subsystem: number) => void;
};
const tempDir = mkdtempSync(join(tmpdir(), "paseo-pe-subsystem-"));
const exePath = join(tempDir, "probe.exe");
const peHeaderOffset = 0x80;
const subsystemOffset = peHeaderOffset + 0x5c;
const bytes = Buffer.alloc(subsystemOffset + 2);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(peHeaderOffset, 0x3c);
bytes.write("PE\u0000\u0000", peHeaderOffset, "ascii");
bytes.writeUInt16LE(2, subsystemOffset);
writeFileSync(exePath, bytes);
try {
setWindowsExecutableSubsystem(exePath, 3);
const patched = readFileSync(exePath);
expect(patched.readUInt16LE(subsystemOffset)).toBe(3);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
}); });
+12 -5
View File
@@ -196,9 +196,16 @@ function buildCheckResult(input: {
}; };
} }
async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promise<void> { function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
if (onBeforeQuit) await onBeforeQuit(); // Use a short delay to allow the renderer to receive the response.
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); 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; const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) { if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(onBeforeQuit); scheduleQuitAndInstall(onBeforeQuit);
return { return {
installed: true, installed: true,
version: readyVersion, version: readyVersion,
@@ -329,7 +336,7 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
downloadedUpdateVersion = readyVersion; downloadedUpdateVersion = readyVersion;
downloading = false; downloading = false;
await performQuitAndInstall(onBeforeQuit); scheduleQuitAndInstall(onBeforeQuit);
return { return {
installed: true, installed: true,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./path"; import { resolveCliInstallSourcePath } from "./cli-install-path";
describe("cli-install-path", () => { describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => { it("uses the bundled shim for packaged macOS installs", () => {
@@ -1 +0,0 @@
export { getCliInstallStatus, installCli } from "./install.js";
@@ -1,71 +0,0 @@
import { promises as fs } from "node:fs";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./path.js";
import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js";
import { ensurePathInShellRc } from "./shell-rc.js";
interface InstallStatus {
installed: boolean;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
@@ -1,31 +0,0 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
export function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
export function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
@@ -1,97 +0,0 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import log from "electron-log/main";
export interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
export function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
export function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
@@ -0,0 +1,318 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
import { syncSkills } from "./skill-sync.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
// Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
function getSkillSyncTargets(): {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
} {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
export async function installSkills(): Promise<InstallStatus> {
const targets = getSkillSyncTargets();
log.info("[integrations] installSkills", targets);
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill install failed", { skillName, error });
},
});
log.info("[integrations] installSkills done", result);
return getSkillsInstallStatus();
}
export async function autoUpdateSkillsIfInstalled(): Promise<{
ran: boolean;
changedFiles: number;
processedSkills: number;
}> {
const targets = getSkillSyncTargets();
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
try {
await fs.access(installedMarker);
} catch {
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
try {
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill auto-update failed", { skillName, error });
},
});
if (result.changedFiles > 0) {
log.info("[integrations] auto-updated paseo skills", result);
} else {
log.info("[integrations] paseo skills already up to date", result);
}
return { ran: true, ...result };
} catch (error) {
log.warn("[integrations] auto-update skills aborted", { error });
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
const accessResults = await Promise.all(
SKILL_NAMES.map((skillName) =>
fs
.access(path.join(claudeDir, skillName, "SKILL.md"))
.then(() => true)
.catch(() => false),
),
);
return { installed: accessResults.every(Boolean) };
}
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { removeSkill, syncSkills } from "./sync"; import { syncSkills } from "./skill-sync";
interface Sandbox { interface Sandbox {
root: string; root: string;
@@ -70,11 +70,6 @@ describe("syncSkills", () => {
"utf-8", "utf-8",
); );
expect(agentsContent).toBe("new paseo content"); expect(agentsContent).toBe("new paseo content");
const claudeContent = await fs.readFile(
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(claudeContent).toBe("new paseo content");
const codexContent = await fs.readFile( const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"), path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8", "utf-8",
@@ -112,10 +107,10 @@ describe("syncSkills", () => {
), ),
).toBe("roles content"); ).toBe("roles content");
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic"); const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true); const lstat = await fs.lstat(claudeLink);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content"); expect(lstat.isSymbolicLink()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe( expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
"roles content", "roles content",
); );
}); });
@@ -259,46 +254,3 @@ describe("syncSkills", () => {
expect(result.processedSkills).toBe(0); expect(result.processedSkills).toBe(0);
}); });
}); });
describe("removeSkill", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes the skill from all three targets when present", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await removeSkill("paseo", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
});
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
});
it("does not throw when targets are missing", async () => {
await expect(
removeSkill("does-not-exist", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
}),
).resolves.toBeUndefined();
});
});
@@ -7,6 +7,7 @@ export interface SkillSyncOptions {
claudeDir: string; claudeDir: string;
codexDir: string; codexDir: string;
skillNames: readonly string[]; skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void; onSkillError?: (skillName: string, error: unknown) => void;
} }
@@ -24,7 +25,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boo
return true; return true;
} }
export async function listFilesRecursive(rootDir: string): Promise<string[]> { async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = []; const out: string[] = [];
async function walk(dir: string): Promise<void> { async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -52,24 +53,38 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<numbe
return changed; return changed;
} }
export interface RemoveSkillTargets { async function ensureClaudeSkillLink(
agentsDir: string; skillName: string,
claudeDir: string; agentsDir: string,
codexDir: string; claudeDir: string,
} platform: NodeJS.Platform,
): Promise<number> {
await fs.mkdir(claudeDir, { recursive: true });
const target = path.join(agentsDir, skillName);
const linkPath = path.join(claudeDir, skillName);
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> { // Always rebuild the link rather than diffing it. fs.rm with force: true is
const paths = [ // a no-op when nothing is there, and matches existing install behavior.
path.join(targets.agentsDir, skillName), // On Windows, `fs.rm` does not follow junctions, so the agents-side content
path.join(targets.claudeDir, skillName), // is preserved.
path.join(targets.codexDir, skillName), await fs.rm(linkPath, { recursive: true, force: true });
];
for (const p of paths) { if (platform === "win32") {
await fs.rm(p, { recursive: true, force: true }); 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> { export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0; let changedFiles = 0;
let processedSkills = 0; let processedSkills = 0;
@@ -85,9 +100,11 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
path.join(options.agentsDir, skillName), path.join(options.agentsDir, skillName),
); );
changedFiles += await syncDirectoryFiles( changedFiles += await ensureClaudeSkillLink(
bundleSkillDir, skillName,
path.join(options.claudeDir, skillName), options.agentsDir,
options.claudeDir,
platform,
); );
changedFiles += await syncDirectoryFiles( changedFiles += await syncDirectoryFiles(
@@ -97,8 +114,7 @@ export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncRe
processedSkills++; processedSkills++;
} catch (error) { } catch (error) {
if (!options.onSkillError) throw error; options.onSkillError?.(skillName, error);
options.onSkillError(skillName, error);
} }
} }
@@ -1,10 +0,0 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";
@@ -1,302 +0,0 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
isPackaged: false,
},
}));
import {
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
interface Sandbox {
root: string;
targets: SkillTargets;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
await fs.mkdir(targets.sourceDir, { recursive: true });
return { root, targets };
}
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(rootDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(sourceDir, name), files);
}
async function writeOnDiskSkill(
agentsDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(agentsDir, name), files);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
}
async function pathExists(p: string): Promise<boolean> {
return fs
.access(p)
.then(() => true)
.catch(() => false);
}
describe("getSkillsStatus", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
});
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
});
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("installSkills / updateSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
const status = await installSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
for (const name of ["paseo", "paseo-loop"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
}
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const first = await installSkills(sandbox.targets);
const second = await installSkills(sandbox.targets);
expect(first).toEqual({ state: "up-to-date", ops: [] });
expect(second).toEqual({ state: "up-to-date", ops: [] });
});
});
describe("uninstallSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await installSkills(sandbox.targets);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const name of PASEO_SKILL_NAMES) {
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
}
for (const name of ["unslop", "tdd", "devbox"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(`user-${name}`);
}
});
it("is a no-op when nothing is installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
});
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
});
@@ -1,164 +0,0 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export const PASEO_SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-chat",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
function resolveSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
const stat = await fs.stat(skillDir).catch(() => null);
if (!stat?.isDirectory()) return null;
const rels = await listFilesRecursive(skillDir);
const files: SkillFiles = new Map();
for (const rel of rels) {
const buf = await fs.readFile(path.join(skillDir, rel));
const sha = createHash("sha256").update(buf).digest("hex");
files.set(toPosix(rel), sha);
}
return files;
}
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
const out = new Map<string, SkillFiles>();
for (const name of PASEO_SKILL_NAMES) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
}
return true;
}
function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
if (disk.size === 0) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
.map((op) => op.name);
if (writes.length > 0) {
await syncSkills({
sourceDir: targets.sourceDir,
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
skillNames: writes,
});
}
for (const op of status.ops) {
if (op.kind !== "delete") continue;
await removeSkill(op.name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets);
}
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
await removeSkill(name, {
agentsDir: t.agentsDir,
claudeDir: t.claudeDir,
codexDir: t.codexDir,
});
}
return getSkillsStatus(t);
}
@@ -1,22 +0,0 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
}
export function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
export function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
export function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
+5
View File
@@ -48,6 +48,7 @@ import {
createBeforeQuitHandler, createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
@@ -660,6 +661,10 @@ async function bootstrap(): Promise<void> {
registerNotificationHandlers(); registerNotificationHandlers();
registerOpenerHandlers(); registerOpenerHandlers();
void autoUpdateSkillsIfInstalled().catch((error) => {
log.warn("[integrations] auto-update skills failed", error);
});
await createMainWindow(); await createMainWindow();
app.on("activate", async () => { app.on("activate", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.70-beta.1", "version": "0.1.69",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.70-beta.1", "version": "0.1.69",
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.70-beta.1", "version": "0.1.69",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist" "dist"
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.70-beta.1", "version": "0.1.69",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -58,8 +58,8 @@
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.70-beta.1", "@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.70-beta.1", "@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2", "@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2", "@mariozechner/pi-ai": "^0.70.2",
@@ -1,6 +1,4 @@
import { type ChildProcess } from "node:child_process"; import { describe, expect, test, vi } from "vitest";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { import type {
PermissionOption, PermissionOption,
PromptResponse, PromptResponse,
@@ -25,7 +23,6 @@ import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js"; import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
interface ACPSessionInternals { interface ACPSessionInternals {
sessionId: string | null; sessionId: string | null;
@@ -117,14 +114,6 @@ function createSessionWithConfig(
); );
} }
function createTerminalChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
child.stdout = new EventEmitter() as ChildProcess["stdout"];
child.stderr = new EventEmitter() as ChildProcess["stderr"];
child.kill = vi.fn(() => true) as ChildProcess["kill"];
return child;
}
function selectConfigOption( function selectConfigOption(
category: "mode" | "model" | "thought_level", category: "mode" | "model" | "thought_level",
values: string[], values: string[],
@@ -314,78 +303,6 @@ describe("createLoggedNdJsonStream", () => {
}); });
}); });
describe("ACPAgentSession terminal tools", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("runs single-string terminal commands through the platform shell", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const shell = spawnUtils.platformShell();
await session.createTerminal({
sessionId: "session-1",
command: "git -C /repo status --short",
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
shell.command,
[...shell.flag, "git -C /repo status --short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("preserves explicit terminal argv", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
await session.createTerminal({
sessionId: "session-1",
command: "git",
args: ["status", "--short"],
cwd: "/repo",
});
expect(spawn).toHaveBeenCalledWith(
"git",
["status", "--short"],
expect.objectContaining({ cwd: "/repo" }),
);
});
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
const child = createTerminalChildStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: "missing-command",
});
child.emit("error", new Error("spawn missing-command ENOENT"));
await expect(
session.waitForTerminalExit({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).rejects.toThrow("spawn missing-command ENOENT");
await expect(
session.terminalOutput({
sessionId: "session-1",
terminalId: terminal.terminalId,
}),
).resolves.toMatchObject({
output: "spawn missing-command ENOENT\n",
truncated: false,
});
});
});
describe("mapACPUsage", () => { describe("mapACPUsage", () => {
test("maps ACP usage fields into Paseo usage", () => { test("maps ACP usage fields into Paseo usage", () => {
expect( expect(
@@ -92,7 +92,7 @@ import {
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js"; import { findExecutable } from "../../../utils/executable.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes( function assertChildWithPipes(
child: ChildProcess, child: ChildProcess,
@@ -106,22 +106,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value); return value != null && typeof value === "object" && !Array.isArray(value);
} }
function resolveTerminalCommand(
command: string,
args?: string[],
): { command: string; args: string[] } {
if (args && args.length > 0) {
return { command, args };
}
if (!/\s/.test(command.trim())) {
return { command, args: [] };
}
const shell = platformShell();
return { command: shell.command, args: [...shell.flag, command] };
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
@@ -1480,8 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const env = Object.fromEntries( const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]), (params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
); );
const terminalCommand = resolveTerminalCommand(params.command, params.args); const child = spawnProcess(params.command, params.args ?? [], {
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd, cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({ ...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
@@ -1496,7 +1479,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolveExit = resolve; resolveExit = resolve;
rejectExit = reject; rejectExit = reject;
}); });
waitForExit.catch(() => undefined);
const entry: TerminalEntry = { const entry: TerminalEntry = {
id: terminalId, id: terminalId,
@@ -1516,11 +1498,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr!.on("data", (chunk: Buffer | string) => child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()), appendTerminalOutput(entry, chunk.toString()),
); );
child.once("error", (error) => { child.once("error", (error) =>
const spawnError = error instanceof Error ? error : new Error(String(error)); rejectExit(error instanceof Error ? error : new Error(String(error))),
appendTerminalOutput(entry, `${spawnError.message}\n`); );
rejectExit(spawnError);
});
child.once("exit", (code, signal) => { child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal }; const exit = { exitCode: code, signal };
entry.exit = exit; entry.exit = exit;
@@ -10,7 +10,6 @@ describe("getClaudeModels", () => {
"claude-opus-4-7", "claude-opus-4-7",
"claude-opus-4-6[1m]", "claude-opus-4-6[1m]",
"claude-opus-4-6", "claude-opus-4-6",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-6", "claude-sonnet-4-6",
"claude-haiku-4-5", "claude-haiku-4-5",
]); ]);
@@ -45,13 +45,6 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
isDefault: true, isDefault: true,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS], thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
}, },
{
provider: "claude",
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{ {
provider: "claude", provider: "claude",
id: "claude-sonnet-4-6", id: "claude-sonnet-4-6",
@@ -49,7 +49,7 @@ import {
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { withTimeout } from "../../../utils/promise-timeout.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
@@ -1228,20 +1228,6 @@ export class OpenCodeAgentClient implements AgentClient {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`; serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
} }
let authValue = "Not checked";
if (resolvedBinary) {
try {
const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], {
...createProviderEnvSpec(),
timeout: 5_000,
});
const text = (stdout.trim() || stderr.trim()).trim();
authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)";
} catch (error) {
authValue = `Error - ${toDiagnosticErrorMessage(error)}`;
}
}
if (available) { if (available) {
try { try {
const models = await this.listModels({ cwd: homedir(), force: false }); const models = await this.listModels({ cwd: homedir(), force: false });
@@ -1277,7 +1263,6 @@ export class OpenCodeAgentClient implements AgentClient {
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
}, },
{ label: "Server", value: serverStatus }, { label: "Server", value: serverStatus },
{ label: "Auth", value: authValue },
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
]), ]),
+12 -5
View File
@@ -3007,11 +3007,18 @@ export class Session {
if (!resolvedWorkspace) { if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`); throw new Error(`Workspace not found: ${msg.workspaceId}`);
} }
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { const snapshot = await this.agentManager.createAgent(
labels, {
workspaceId: resolvedWorkspace.workspaceId, ...sessionConfig,
initialPrompt: trimmedPrompt, cwd: resolvedWorkspace.cwd,
}); },
undefined,
{
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
},
);
await this.forwardAgentUpdate(snapshot); await this.forwardAgentUpdate(snapshot);
await this.sendInitialCreateAgentPrompt({ await this.sendInitialCreateAgentPrompt({
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
@@ -10,18 +10,6 @@ import type {
EditorTargetDescriptorPayload, EditorTargetDescriptorPayload,
SessionOutboundMessage, SessionOutboundMessage,
} from "../shared/messages.js"; } 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 type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { import {
@@ -42,8 +30,6 @@ import {
findByType, findByType,
} from "./test-utils/session-stubs.js"; } from "./test-utils/session-stubs.js";
import { import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
} from "./workspace-registry.js"; } 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( function createSessionForWorkspaceTests(
options: { options: {
appVersion?: string | null; appVersion?: string | null;
@@ -470,135 +361,6 @@ function createSessionForWorkspaceTests(
return session; 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 () => { test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
const storedRecord = { const storedRecord = {
@@ -1,6 +1,5 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -12,13 +11,11 @@ import {
import { import {
getCheckoutDiff as getCheckoutDiffUncached, getCheckoutDiff as getCheckoutDiffUncached,
getCheckoutStatus as getCheckoutStatusUncached, getCheckoutStatus as getCheckoutStatusUncached,
resolveAbsoluteGitDir as resolveAbsoluteGitDirReal,
type CheckoutDiffCompare, type CheckoutDiffCompare,
type CheckoutDiffResult, type CheckoutDiffResult,
type CheckoutStatusGit, type CheckoutStatusGit,
type PullRequestStatusResult, type PullRequestStatusResult,
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
import { import {
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
@@ -210,7 +207,6 @@ interface CreateServiceOptions {
runGitFetch?: ReturnType<typeof vi.fn>; runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>; runGitCommand?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>; watch?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
now?: () => Date; now?: () => Date;
} }
@@ -1458,48 +1454,4 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose(); 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; 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. // 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 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 = const linuxWatchReaddirConcurrency =
parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16; 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 repoTargets = new Map<string, RepoGitTarget>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>(); private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<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< private readonly branchValidationCache = new Map<
string, string,
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult> WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
@@ -1199,9 +1195,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to refresh Linux working tree watchers", "Failed to refresh Linux working tree watchers",
); );
} }
if (target.linuxTreeRefreshQueued) {
await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS));
}
} while (target.linuxTreeRefreshQueued); } while (target.linuxTreeRefreshQueued);
})(); })();
@@ -1213,17 +1206,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} }
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> { private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
const ignored = await this.loadLinuxIgnoredDirs(rootPath);
const directories: string[] = []; const directories: string[] = [];
let currentLevel: string[] = [rootPath]; let currentLevel: string[] = [rootPath];
let capped = false;
while (currentLevel.length > 0) { while (currentLevel.length > 0) {
directories.push(...currentLevel); directories.push(...currentLevel);
if (directories.length >= LINUX_WATCH_MAX_DIRS) {
capped = true;
break;
}
const readResults = await Promise.all( const readResults = await Promise.all(
currentLevel.map((directory) => currentLevel.map((directory) =>
linuxWatchReaddirLimit(async () => { linuxWatchReaddirLimit(async () => {
@@ -1244,59 +1231,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!entry.isDirectory() || entry.name === ".git") { if (!entry.isDirectory() || entry.name === ".git") {
continue; continue;
} }
const childPath = join(directory, entry.name); nextLevel.push(join(directory, entry.name));
if (ignored.has(childPath)) {
continue;
}
nextLevel.push(childPath);
} }
} }
currentLevel = nextLevel; 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; 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( private async refreshWorkspaceTarget(
target: WorkspaceGitTarget, target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest, request: WorkspaceGitRefreshRequest,
@@ -1644,9 +1587,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.watchers = []; target.watchers = [];
target.watchedPaths.clear(); target.watchedPaths.clear();
target.listeners.clear(); target.listeners.clear();
if (target.repoWatchPath) {
this.linuxIgnoredDirsCache.delete(target.repoWatchPath);
}
} }
private closeRepoTarget(target: RepoGitTarget): void { 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 () => { it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
const runner = createRunner([searchPullRequestJson("PR title")]); const runner = createRunner([searchPullRequestJson("PR title")]);
const service = createGitHubService({ const service = createGitHubService({
+2 -12
View File
@@ -933,12 +933,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const readOptions: GitHubReadOptions = input.force const readOptions: GitHubReadOptions = input.force
? { force: true, reason: input.reason } ? { force: true, reason: input.reason }
: { force: false, reason: input.reason }; : { force: false, reason: input.reason };
const query = normalizeGitHubSearchQuery(input.query);
const [issuesResult, prsResult] = await Promise.allSettled([ const [issuesResult, prsResult] = await Promise.allSettled([
shouldFetchIssues shouldFetchIssues
? this.listIssues({ ? this.listIssues({
cwd: input.cwd, cwd: input.cwd,
query, query: input.query,
limit: input.limit, limit: input.limit,
...readOptions, ...readOptions,
}) })
@@ -946,7 +945,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
shouldFetchPullRequests shouldFetchPullRequests
? this.listPullRequests({ ? this.listPullRequests({
cwd: input.cwd, cwd: input.cwd,
query, query: input.query,
limit: input.limit, limit: input.limit,
...readOptions, ...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 { function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`; return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.70-beta.1", "version": "0.1.69",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {