Compare commits

..
82 changed files with 980 additions and 1895 deletions
+6 -3
View File
@@ -1,8 +1,11 @@
name: Deploy Relay
on:
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
# A release or main push must not redeploy the temporary Cloudflare bridge.
push:
branches: [main]
paths:
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
workflow_dispatch:
jobs:
@@ -24,6 +27,6 @@ jobs:
run: npm run typecheck --workspace=@getpaseo/relay
- name: Deploy worker
run: cd packages/relay && npx wrangler deploy
run: cd packages/relay && npx wrangler deploy -c wrangler.staging.toml
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-17
View File
@@ -1,22 +1,5 @@
# Changelog
## 0.1.106 - 2026-07-12
### Added
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
- Switching between workspaces no longer flashes a white screen
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
## 0.1.105 - 2026-07-10
### Added
+1 -1
View File
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
-12
View File
@@ -96,18 +96,6 @@ Keep workspace identity and retention outside native-stack `getId` and
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
Expo Router stack. React Navigation otherwise paints each native stack screen
with its light default background. A screen-level wrapper can hide that surface
while settled, but Android may expose it for one frame when navigation crosses
from a nested stack to its parent stack. This is especially visible when an
app-wide route such as `/new` opens from a dark workspace.
Do not read the active theme with `useUnistyles()` in a layout to build
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native
-1
View File
@@ -4,7 +4,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
+1 -1
View File
@@ -28,7 +28,7 @@ Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
-2
View File
@@ -52,8 +52,6 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
-5
View File
@@ -9,11 +9,6 @@ The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
an agent cannot restore an oversized tool payload.
## Presence is not delivery
Client heartbeat reports presence:
+1 -1
View File
@@ -1 +1 @@
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36170,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36421,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.105",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36678,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.106",
"version": "0.1.105",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38582,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,5 +1,4 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
@@ -12,7 +11,7 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -22,6 +21,10 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
@@ -156,21 +159,17 @@ test.describe("Project with no workspaces persists", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
expect(existsSync(workspace.repoPath)).toBe(true);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
+4 -3
View File
@@ -39,9 +39,10 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean worktree archives with no prompt; if the host reports unsynced work the app
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
// either way.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
@@ -1,26 +0,0 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Workspace archive shortcut", () => {
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+Backspace`);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
expect(existsSync(workspace.repoPath)).toBe(true);
} finally {
await workspace.cleanup();
}
});
});
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
return warning;
}
test.describe("Workspace archive risk warning for worktree backing", () => {
test.describe("Worktree archive risk warning", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -97,7 +97,7 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
page,
}) => {
const serverId = getServerId();
+6 -44
View File
@@ -8,11 +8,11 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Workspace archive with worktree backing", () => {
test.describe("Worktree archive", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -33,7 +33,9 @@ test.describe("Workspace archive with worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
@@ -47,51 +49,11 @@ test.describe("Workspace archive with worktree backing", () => {
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const first = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `shared-archive-${Date.now()}`,
});
createdWorktreeDirectories.add(first.workspaceDirectory);
const siblingPayload = await client.createWorkspace({
source: {
kind: "directory",
path: first.workspaceDirectory,
},
title: "Second workspace",
});
if (!siblingPayload.workspace) {
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
}
const sibling = siblingPayload.workspace;
expect(sibling.workspaceKind).toBe("worktree");
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, first.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
expect(existsSync(first.workspaceDirectory)).toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, sibling.id);
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"main": "index.ts",
"scripts": {
+13 -8
View File
@@ -52,7 +52,6 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -785,15 +784,21 @@ function FaviconStatusSync() {
return null;
}
const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
const stackScreenOptions = useMemo(
() => ({
headerShown: false,
animation: "none" as const,
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}),
[theme.colors.surface0],
);
return (
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<Stack screenOptions={stackScreenOptions}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -810,7 +815,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</ThemedStack>
</Stack>
);
}
@@ -2,7 +2,6 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { ThemedStack } from "@/navigation/themed-stack";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
const HOST_STACK_SCREEN_OPTIONS = {
@@ -34,14 +33,14 @@ function KnownHostRoute() {
}
const stack = (
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
</ThemedStack>
</Stack>
);
if (!routeServerId) {
@@ -77,6 +77,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -111,7 +112,7 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -283,6 +284,16 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function getWorkspaceArchiveStatus(
isWorktree: boolean,
archiveStatus: "idle" | "pending" | "success",
isArchivingWorkspace: boolean,
): "idle" | "pending" | "success" {
if (isWorktree) return archiveStatus;
if (isArchivingWorkspace) return "pending";
return "idle";
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -1443,7 +1454,20 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1455,6 +1479,7 @@ function WorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -1521,7 +1546,7 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -1533,8 +1558,8 @@ function WorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -1559,7 +1584,11 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -23,9 +23,10 @@ import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -329,7 +330,20 @@ function StatusWorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -344,6 +358,7 @@ function StatusWorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -393,7 +408,7 @@ function StatusWorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -405,8 +420,8 @@ function StatusWorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -415,6 +430,13 @@ function StatusWorkspaceRowWithMenu({
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
@@ -426,7 +448,7 @@ function StatusWorkspaceRowWithMenu({
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
@@ -11,13 +11,14 @@ import { DiffStat } from "@/components/diff-stat";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
@@ -65,7 +66,20 @@ export function SidebarWorkspaceRow({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -80,6 +94,7 @@ export function SidebarWorkspaceRow({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -146,7 +161,7 @@ export function SidebarWorkspaceRow({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -158,8 +173,8 @@ export function SidebarWorkspaceRow({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -168,6 +183,13 @@ export function SidebarWorkspaceRow({
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
@@ -183,7 +205,7 @@ export function SidebarWorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={archiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -1,8 +1,9 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
@@ -14,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -24,6 +26,7 @@ export function markWorkspaceArchivePending(input: {
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -51,6 +54,7 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
@@ -63,7 +67,21 @@ export function isWorkspaceArchivePending(input: {
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -73,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
+10 -10
View File
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.119.2",
version: "2.118.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
},
{
id: "codewhale",
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.27",
version: "0.2.26",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
},
{
id: "dirac",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.170.0",
version: "0.169.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.43",
version: "1.0.41",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.9",
version: "0.19.8",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -48,7 +48,7 @@ function GitActionMenuItem({
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
const trailing = useMemo(
() =>
action.id === "archive-workspace" && archiveShortcutKeys ? (
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined,
[action.id, archiveShortcutKeys],
@@ -58,7 +58,7 @@ function GitActionMenuItem({
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={
action.id === "archive-workspace"
action.id === "archive-worktree"
? "workspace-archive-action"
: `changes-menu-${action.id}`
}
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
+141
View File
@@ -2,10 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/git/actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -25,13 +31,33 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/tmp/repo",
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo/worktrees/feature";
const workspaceId = "ws-feature";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -39,6 +65,7 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -267,4 +294,118 @@ describe("checkout-git-actions-store", () => {
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
).toBe("idle");
});
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(
["sidebarPaseoWorktreeList", serverId, "/tmp"],
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
);
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
]);
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(
isWorkspaceArchivePending({
serverId,
workspaceId,
}),
).toBe(true);
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(false);
});
it("archives on the server even when its workspace cannot be resolved", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
await useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
// The server archive is keyed by worktreePath and must run regardless.
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
// The optimistic client-side mark is never keyed by the path.
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
});
it("restores an optimistically hidden worktree when archive fails", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
featureWorkspace,
);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
listSnapshot,
);
});
it("reports local archive pending only while the archive action is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
});
});
+187 -1
View File
@@ -1,7 +1,19 @@
import type { QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/data/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -24,7 +36,8 @@ export type CheckoutGitAsyncActionId =
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base";
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
@@ -76,6 +89,119 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}
function invalidateWorktreeList() {
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
}
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
const removeFromList = (current: unknown) => {
if (!Array.isArray(current)) {
return current;
}
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
return filtered.length === current.length ? current : filtered;
};
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "paseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
}
interface WorktreeArchiveSnapshot {
workspace: WorkspaceDescriptor | null;
worktreeLists: Array<[QueryKey, unknown]>;
}
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
return (
Array.isArray(input.queryKey) &&
(input.queryKey[0] === "paseoWorktreeList" ||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
input.queryKey[1] === input.serverId
);
}
function snapshotWorktreeArchiveState(input: {
serverId: string;
workspaceId: string | undefined;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceKey = input.workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
: null;
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
predicate: (query) =>
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
}),
};
}
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
}
function restoreWorktreeArchiveState(input: {
serverId: string;
snapshot: WorktreeArchiveSnapshot;
}): void {
if (input.snapshot.workspace) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
}
for (const [queryKey, data] of input.snapshot.worktreeLists) {
appQueryClient.setQueryData(queryKey, data);
}
}
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
@@ -83,6 +209,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
return `${key}::${actionId}`;
}
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
return (
useCheckoutGitActionsStore.getState().getStatus({
serverId: input.serverId,
cwd: input.cwd,
actionId: "archive-worktree",
}) === "pending"
);
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
@@ -111,6 +247,12 @@ interface CheckoutGitActionsStoreState {
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
workspaceId?: string;
}) => Promise<void>;
}
async function runCheckoutAction({
@@ -348,6 +490,50 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
// The server archive is keyed by worktreePath and must always run. The
// optimistic client-side updates are keyed by workspace id, so they only
// apply when the caller passes the workspace id and it resolves in the
// local store.
const workspace = snapshot.workspace;
if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}
removeWorktreeFromCachedLists({ serverId, worktreePath });
try {
const payload = await client.archivePaseoWorktree({
worktreePath,
...(workspaceId !== undefined ? { workspaceId } : {}),
});
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
if (workspace) {
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
}
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
if (workspace) {
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
}
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
+8 -40
View File
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"archive-workspace": {
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
@@ -140,13 +140,7 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.primary).toBeNull();
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"archive-workspace",
]);
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
});
it("prioritizes pull when the branch is behind origin", () => {
@@ -278,7 +272,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -359,33 +352,12 @@ describe("git-actions-policy", () => {
);
});
it("hides Git actions for a non-Git workspace", () => {
const directory = buildGitActions(createInput({ isGit: false }));
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
});
it("offers archive workspace for Git checkouts and worktrees", () => {
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
const worktree = buildGitActions(
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
);
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("does not promote archive to primary for an idle regular Git checkout", () => {
const actions = buildGitActions(createInput());
expect(actions.primary).toBeNull();
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
@@ -569,7 +541,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -746,7 +717,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -787,7 +757,6 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"enable-pr-auto-merge-squash",
"archive-workspace",
]);
expect(
actions.secondary.some((action) =>
@@ -930,7 +899,6 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"merge-pr-merge",
"archive-workspace",
]);
});
@@ -984,7 +952,7 @@ describe("git-actions-policy", () => {
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
+17 -20
View File
@@ -23,7 +23,7 @@ export type GitActionId =
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-workspace";
| "archive-worktree";
export interface GitAction {
id: GitActionId;
@@ -282,16 +282,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-workspace", {
id: "archive-workspace",
allActions.set("archive-worktree", {
id: "archive-worktree",
label: i18n.t("workspace.git.actions.archive.label"),
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
successLabel: i18n.t("workspace.git.actions.archive.success"),
disabled: input.runtime["archive-workspace"].disabled,
status: input.runtime["archive-workspace"].status,
icon: input.runtime["archive-workspace"].icon,
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-workspace"].handler,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
@@ -301,20 +305,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isOnBaseBranch) {
secondaryIds.push(...getFeatureActionIds(input));
}
secondaryIds.push("archive-workspace");
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
}
return {
primary,
secondary: secondaryIds
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
.map((id) => allActions.get(id)!),
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
@@ -346,13 +350,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
// Only Paseo-owned worktrees get Archive as a fallback primary action.
// Regular Git checkouts should not show the destructive archive CTA by default.
if (input.isPaseoOwnedWorktree) {
return "archive-workspace";
}
return null;
}
+39 -84
View File
@@ -14,7 +14,7 @@ import {
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
@@ -22,7 +22,6 @@ import {
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -98,12 +97,15 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
}
function computeShouldPromoteArchive(input: {
isPaseoOwnedWorktree: boolean;
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isMergedPullRequest: boolean;
}): boolean {
return (
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
input.isPaseoOwnedWorktree &&
!input.hasUncommittedChanges &&
(input.postShipArchiveSuggested || input.isMergedPullRequest)
);
}
@@ -129,6 +131,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
shouldPromoteArchive: computeShouldPromoteArchive({
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
@@ -170,52 +173,6 @@ interface UseWorkspaceScreenArchiveControllerInput {
t: (key: string, options?: Record<string, unknown>) => string;
}
function resolveArchiveWorkspaceDescriptor(input: {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
}): WorkspaceDescriptor | null {
const activeWorkspaceKey = input.activeWorkspaceSelection
? resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceId: input.activeWorkspaceSelection.workspaceId,
})
: null;
if (activeWorkspaceKey) {
return input.workspaces?.get(activeWorkspaceKey) ?? null;
}
if (!input.workspaceDirectory) {
return null;
}
for (const candidate of input.workspaces?.values() ?? []) {
if (candidate.workspaceDirectory === input.workspaceDirectory) {
return candidate;
}
}
return null;
}
function resolveWorkspaceArchiveRisk(
workspace: WorkspaceDescriptor | null,
gitStatus: CheckoutStatusPayload | null,
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
return {
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
};
}
function canArchiveWorkspace(
workspace: WorkspaceDescriptor | null,
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
): boolean {
return (
workspace !== null &&
(workspace.workspaceKind !== "worktree" ||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
);
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
@@ -225,28 +182,28 @@ function useWorkspaceScreenArchiveController({
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const workspaceDescriptor = useMemo(
() =>
resolveArchiveWorkspaceDescriptor({
workspaces: sessionWorkspaces,
activeWorkspaceSelection,
workspaceDirectory,
}),
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
);
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
const archiveWorkspaceRecord = useMemo(() => {
if (!workspaceDirectory) {
return null;
}
for (const candidate of sessionWorkspaces?.values() ?? []) {
if (candidate.workspaceDirectory === workspaceDirectory) {
return candidate;
}
}
return null;
}, [sessionWorkspaces, workspaceDirectory]);
const controller = useWorkspaceArchive({
return useWorkspaceArchive({
serverId,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
workspaceDirectory,
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
name: archiveWorkspaceRecord?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
@@ -258,12 +215,6 @@ function useWorkspaceScreenArchiveController({
});
},
});
return {
...controller,
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
};
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
@@ -391,6 +342,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
const runPull = useCheckoutGitActionsStore((s) => s.pull);
@@ -579,7 +533,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
t,
});
const handleArchiveWorkspace = useCallback(() => {
const handleArchiveWorktree = useCallback(() => {
archiveController.archive();
}, [archiveController]);
@@ -724,11 +678,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-workspace": {
disabled: !archiveController.canArchive || archiveController.isArchiving,
status: archiveController.isArchiving ? "pending" : "idle",
"archive-worktree": {
disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorkspace,
handler: handleArchiveWorktree,
},
},
}),
@@ -769,8 +723,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
archiveStatus,
handleCommit,
handlePull,
handlePush,
@@ -781,7 +734,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
handleArchiveWorktree,
icons,
baseRef,
],
@@ -937,7 +890,7 @@ function getTranslatedGitActionLabels(
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
successLabel: t("workspace.git.actions.mergeFromBase.success"),
};
case "archive-workspace":
case "archive-worktree":
return {
label: t("workspace.git.actions.archive.label"),
pendingLabel: t("workspace.git.actions.archive.pending"),
@@ -992,6 +945,8 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.updateNoBase",
"Update isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.updateDirty",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Merge PR isn't available because there isn't a pull request yet":
@@ -1024,7 +979,7 @@ function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
+7 -3
View File
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceActionsProps {
interface WorkspaceGitActionsProps {
serverId: string;
cwd: string;
hideLabels?: boolean;
@@ -47,12 +47,16 @@ const ICONS = {
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { gitActions, isGit } = useGitActions({
serverId,
cwd,
icons: ICONS,
});
if (!isGit) {
return null;
}
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}
@@ -6,11 +6,11 @@ import {
toWorktreeArchiveRisk,
} from "@/git/worktree-archive-warning";
describe("workspace archive warning for worktree backing", () => {
describe("worktree archive warning", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
workspaceName: "feature",
worktreeName: "feature",
isDirty: false,
aheadOfOrigin: 0,
diffStat: null,
@@ -51,7 +51,7 @@ describe("workspace archive warning for worktree backing", () => {
it("includes every archive risk in the confirmation copy", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
workspaceName: "risky-feature",
worktreeName: "risky-feature",
isDirty: true,
aheadOfOrigin: 1,
diffStat: { additions: 1, deletions: 3 },
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
workspaceName: string;
worktreeName: string;
}
export interface WorktreeArchiveWarningLabels {
title: (workspaceName: string) => string;
title: (worktreeName: string) => string;
confirm: string;
cancel: string;
uncommittedChanges: string;
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
}
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
}
return await confirmDialog({
title: labels.title(input.workspaceName),
title: labels.title(input.worktreeName),
message,
confirmLabel: labels.confirm,
cancelLabel: labels.cancel,
+9 -5
View File
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
success: "تم التحديث",
},
archive: {
label: "أرشفة مساحة العمل",
label: "أرشفة شجرة العمل",
pending: "أرشفة...",
success: "مؤرشف",
},
@@ -661,6 +661,8 @@ export const ar: TranslationResources = {
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
@@ -683,9 +685,11 @@ export const ar: TranslationResources = {
baseRefUnavailable: "المرجع الأساسي غير متاح",
failedMerge: "فشل الدمج",
failedMergeFromBase: "فشل الدمج من القاعدة",
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
failedArchive: "فشل في أرشفة شجرة العمل",
},
archiveWarning: {
title: 'الأرشيف "{{workspaceName}}"؟',
title: 'الأرشيف "{{worktreeName}}"؟',
confirm: "أرشيف",
cancel: "يلغي",
uncommittedChanges: "تغييرات غير ملتزم بها",
@@ -835,7 +839,7 @@ export const ar: TranslationResources = {
copyBranchName: "انسخ اسم الفرع",
rename: "إعادة تسمية مساحة العمل",
archive: "أرشيف",
archiveWorkspace: "أرشفة مساحة العمل",
archiveWorktree: "أرشفة شجرة العمل",
hideFromSidebar: "إخفاء من الشريط الجانبي",
archiving: "أرشفة...",
hiding: "إخفاء...",
@@ -858,7 +862,7 @@ export const ar: TranslationResources = {
branchNameCopied: "تم نسخ اسم الفرع",
hostDisconnected: "Host غير متصل",
hideFailed: "فشل في إخفاء مساحة العمل",
archiveFailed: "فشل في أرشفة مساحة العمل",
archiveFailed: "فشل في أرشفة شجرة العمل",
},
},
},
@@ -1601,7 +1605,7 @@ export const ar: TranslationResources = {
openProject: "مشروع مفتوح",
newWorkspace: "مساحة عمل جديدة",
newWorktree: "شجرة عمل جديدة",
archiveWorkspace: "أرشفة مساحة العمل",
archiveWorktree: "أرشفة شجرة العمل",
newTab: "علامة تبويب جديدة",
closeCurrentTab: "إغلاق علامة التبويب الحالية",
jumpToWorkspace: "انتقل إلى مساحة العمل",
+9 -5
View File
@@ -618,7 +618,7 @@ export const en = {
success: "Updated",
},
archive: {
label: "Archive workspace",
label: "Archive worktree",
pending: "Archiving...",
success: "Archived",
},
@@ -667,6 +667,8 @@ export const en = {
"Update isn't available while you have local changes so commit or stash them first",
updateCurrent:
"Update isn't available because this branch is already up to date with {{baseRef}}",
archiveNotWorktree:
"Archive isn't available here because this workspace was not created as a Paseo worktree",
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
@@ -690,9 +692,11 @@ export const en = {
baseRefUnavailable: "Base ref unavailable",
failedMerge: "Failed to merge",
failedMergeFromBase: "Failed to merge from base",
worktreePathUnavailable: "Worktree path unavailable",
failedArchive: "Failed to archive worktree",
},
archiveWarning: {
title: 'Archive "{{workspaceName}}"?',
title: 'Archive "{{worktreeName}}"?',
confirm: "Archive",
cancel: "Cancel",
uncommittedChanges: "Uncommitted changes",
@@ -842,7 +846,7 @@ export const en = {
copyBranchName: "Copy branch name",
rename: "Rename workspace",
archive: "Archive",
archiveWorkspace: "Archive workspace",
archiveWorktree: "Archive worktree",
hideFromSidebar: "Hide from sidebar",
archiving: "Archiving...",
hiding: "Hiding...",
@@ -865,7 +869,7 @@ export const en = {
branchNameCopied: "Branch name copied",
hostDisconnected: "Host is not connected",
hideFailed: "Failed to hide workspace",
archiveFailed: "Failed to archive workspace",
archiveFailed: "Failed to archive worktree",
},
},
},
@@ -1608,7 +1612,7 @@ export const en = {
openProject: "Open project",
newWorkspace: "New workspace",
newWorktree: "New worktree",
archiveWorkspace: "Archive workspace",
archiveWorktree: "Archive worktree",
newTab: "New tab",
closeCurrentTab: "Close current tab",
jumpToWorkspace: "Jump to workspace",
+9 -5
View File
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
success: "Actualizado",
},
archive: {
label: "Archivar espacio de trabajo",
label: "Árbol de trabajo de archivo",
pending: "Archivando...",
success: "Archivado",
},
@@ -679,6 +679,8 @@ export const es: TranslationResources = {
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
updateCurrent:
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
archiveNotWorktree:
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
mergePrNoGithub:
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
mergePrMissing:
@@ -710,9 +712,11 @@ export const es: TranslationResources = {
baseRefUnavailable: "Referencia base no disponible",
failedMerge: "No se pudo fusionar",
failedMergeFromBase: "No se pudo fusionar desde la base",
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
failedArchive: "No se pudo archivar el árbol de trabajo",
},
archiveWarning: {
title: '¿Archivo "{{workspaceName}}"?',
title: '¿Archivo "{{worktreeName}}"?',
confirm: "Archivo",
cancel: "Cancelar",
uncommittedChanges: "Cambios no confirmados",
@@ -862,7 +866,7 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de sucursal",
rename: "Cambiar nombre del espacio de trabajo",
archive: "Archivo",
archiveWorkspace: "Archivar espacio de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
hideFromSidebar: "Ocultar de la barra lateral",
archiving: "Archivando...",
hiding: "Ocultación...",
@@ -885,7 +889,7 @@ export const es: TranslationResources = {
branchNameCopied: "Nombre de la sucursal copiado",
hostDisconnected: "Hostno está conectado",
hideFailed: "No se pudo ocultar el espacio de trabajo",
archiveFailed: "No se pudo archivar el espacio de trabajo",
archiveFailed: "No se pudo archivar el árbol de trabajo",
},
},
},
@@ -1643,7 +1647,7 @@ export const es: TranslationResources = {
openProject: "Abrir proyecto",
newWorkspace: "Nuevo espacio de trabajo",
newWorktree: "Nuevo árbol de trabajo",
archiveWorkspace: "Archivar espacio de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
newTab: "Nueva pestaña",
closeCurrentTab: "Cerrar pestaña actual",
jumpToWorkspace: "Saltar al espacio de trabajo",
+9 -5
View File
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
success: "Mis à jour",
},
archive: {
label: "Archiver l’espace de travail",
label: "Arbre de travail d'archivage",
pending: "Archivage...",
success: "Archivé",
},
@@ -679,6 +679,8 @@ export const fr: TranslationResources = {
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
updateCurrent:
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
archiveNotWorktree:
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
mergePrNoGithub:
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
mergePrMissing:
@@ -709,9 +711,11 @@ export const fr: TranslationResources = {
baseRefUnavailable: "Réf de base indisponible",
failedMerge: "Échec de la fusion",
failedMergeFromBase: "Échec de la fusion à partir de la base",
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
failedArchive: "Échec de l'archivage de l'arbre de travail",
},
archiveWarning: {
title: "Archiver «{{workspaceName}}»?",
title: "Archiver «{{worktreeName}}»?",
confirm: "Archive",
cancel: "Annuler",
uncommittedChanges: "Modifications non validées",
@@ -861,7 +865,7 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
rename: "Renommer l'espace de travail",
archive: "Archive",
archiveWorkspace: "Archiver l’espace de travail",
archiveWorktree: "Arbre de travail d'archivage",
hideFromSidebar: "Masquer de la barre latérale",
archiving: "Archivage...",
hiding: "Dissimulation...",
@@ -884,7 +888,7 @@ export const fr: TranslationResources = {
branchNameCopied: "Nom de la succursale copié",
hostDisconnected: "Hostn'est pas connecté",
hideFailed: "Échec du masquage de l'espace de travail",
archiveFailed: "Échec de l'archivage de l'espace de travail",
archiveFailed: "Échec de l'archivage de l'arbre de travail",
},
},
},
@@ -1646,7 +1650,7 @@ export const fr: TranslationResources = {
openProject: "Projet ouvert",
newWorkspace: "Nouvel espace de travail",
newWorktree: "Nouvel arbre de travail",
archiveWorkspace: "Archiver l’espace de travail",
archiveWorktree: "Arbre de travail d'archivage",
newTab: "Nouvel onglet",
closeCurrentTab: "Fermer l'onglet actuel",
jumpToWorkspace: "Accéder à l'espace de travail",
+9 -5
View File
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
success: "更新しました",
},
archive: {
label: "ワークスペースをアーカイブ",
label: "ワークツリーをアーカイブ",
pending: "アーカイブ中...",
success: "アーカイブしました",
},
@@ -670,6 +670,8 @@ export const ja: TranslationResources = {
updateDirty:
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
@@ -695,9 +697,11 @@ export const ja: TranslationResources = {
baseRefUnavailable: "ベースRefが利用できません",
failedMerge: "マージに失敗しました",
failedMergeFromBase: "ベースからのマージに失敗しました",
worktreePathUnavailable: "ワークツリーパスが利用できません",
failedArchive: "ワークツリーのアーカイブに失敗しました",
},
archiveWarning: {
title: '"{{workspaceName}}"をアーカイブしますか?',
title: '"{{worktreeName}}"をアーカイブしますか?',
confirm: "アーカイブ",
cancel: "キャンセル",
uncommittedChanges: "未コミットの変更",
@@ -847,7 +851,7 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
rename: "ワークスペースの名前を変更",
archive: "アーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
hideFromSidebar: "サイドバーから非表示",
archiving: "アーカイブ中...",
hiding: "非表示にしています...",
@@ -870,7 +874,7 @@ export const ja: TranslationResources = {
branchNameCopied: "ブランチ名をコピーしました",
hostDisconnected: "ホストが接続されていません",
hideFailed: "ワークスペースの非表示に失敗しました",
archiveFailed: "ワークスペースのアーカイブに失敗しました",
archiveFailed: "ワークツリーのアーカイブに失敗しました",
},
},
},
@@ -1618,7 +1622,7 @@ export const ja: TranslationResources = {
openProject: "プロジェクトを開く",
newWorkspace: "新しいワークスペース",
newWorktree: "新しいワークツリー",
archiveWorkspace: "ワークスペースをアーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
newTab: "新しいタブ",
closeCurrentTab: "現在のタブを閉じる",
jumpToWorkspace: "ワークスペースにジャンプ",
+9 -5
View File
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
success: "Atualizado",
},
archive: {
label: "Arquivar workspace",
label: "Arquivar worktree",
pending: "Arquivando...",
success: "Arquivado",
},
@@ -675,6 +675,8 @@ export const ptBR: TranslationResources = {
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
updateCurrent:
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
archiveNotWorktree:
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
mergePrNoGithub:
"Merge da PR não está disponível agora porque o GitHub não está conectado",
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
@@ -701,9 +703,11 @@ export const ptBR: TranslationResources = {
baseRefUnavailable: "Ref base indisponível",
failedMerge: "Falha ao fazer merge",
failedMergeFromBase: "Falha ao fazer merge da base",
worktreePathUnavailable: "Caminho do worktree indisponível",
failedArchive: "Falha ao arquivar worktree",
},
archiveWarning: {
title: 'Arquivar "{{workspaceName}}"?',
title: 'Arquivar "{{worktreeName}}"?',
confirm: "Arquivar",
cancel: "Cancelar",
uncommittedChanges: "Alterações sem commit",
@@ -853,7 +857,7 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
rename: "Renomear workspace",
archive: "Arquivar",
archiveWorkspace: "Arquivar workspace",
archiveWorktree: "Arquivar worktree",
hideFromSidebar: "Ocultar da barra lateral",
archiving: "Arquivando...",
hiding: "Ocultando...",
@@ -876,7 +880,7 @@ export const ptBR: TranslationResources = {
branchNameCopied: "Nome da branch copiado",
hostDisconnected: "Host não está conectado",
hideFailed: "Falha ao ocultar workspace",
archiveFailed: "Falha ao arquivar workspace",
archiveFailed: "Falha ao arquivar worktree",
},
},
},
@@ -1627,7 +1631,7 @@ export const ptBR: TranslationResources = {
openProject: "Abrir projeto",
newWorkspace: "Novo workspace",
newWorktree: "Novo worktree",
archiveWorkspace: "Arquivar workspace",
archiveWorktree: "Arquivar worktree",
newTab: "Nova aba",
closeCurrentTab: "Fechar aba atual",
jumpToWorkspace: "Ir para workspace",
+9 -5
View File
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
success: "Обновлено",
},
archive: {
label: "Архивировать рабочее пространство",
label: "Архив рабочего дерева",
pending: "Архивирование...",
success: "В архиве",
},
@@ -675,6 +675,8 @@ export const ru: TranslationResources = {
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
updateCurrent:
"Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.",
archiveNotWorktree:
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
mergePrDraft:
@@ -702,9 +704,11 @@ export const ru: TranslationResources = {
baseRefUnavailable: "Базовый номер недоступен.",
failedMerge: "Не удалось объединиться",
failedMergeFromBase: "Не удалось объединиться с базой.",
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
failedArchive: "Не удалось заархивировать рабочее дерево.",
},
archiveWarning: {
title: 'Архив "{{workspaceName}}"?',
title: 'Архив "{{worktreeName}}"?',
confirm: "Архив",
cancel: "Отмена",
uncommittedChanges: "Незафиксированные изменения",
@@ -854,7 +858,7 @@ export const ru: TranslationResources = {
copyBranchName: "Скопировать название ветки",
rename: "Переименовать рабочую область",
archive: "Архив",
archiveWorkspace: "Архивировать рабочее пространство",
archiveWorktree: "Архив рабочего дерева",
hideFromSidebar: "Скрыть с боковой панели",
archiving: "Архивирование...",
hiding: "Скрытие...",
@@ -877,7 +881,7 @@ export const ru: TranslationResources = {
branchNameCopied: "Название филиала скопировано.",
hostDisconnected: "Host не подключен",
hideFailed: "Не удалось скрыть рабочую область.",
archiveFailed: "Не удалось заархивировать рабочее пространство.",
archiveFailed: "Не удалось заархивировать рабочее дерево.",
},
},
},
@@ -1635,7 +1639,7 @@ export const ru: TranslationResources = {
openProject: "Открыть проект",
newWorkspace: "Новое рабочее пространство",
newWorktree: "Новое рабочее дерево",
archiveWorkspace: "Архивировать рабочее пространство",
archiveWorktree: "Архив рабочего дерева",
newTab: "Новая вкладка",
closeCurrentTab: "Закрыть текущую вкладку",
jumpToWorkspace: "Перейти в рабочую область",
+8 -5
View File
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
success: "已更新",
},
archive: {
label: "归档工作区",
label: "归档 worktree",
pending: "正在归档...",
success: "已归档",
},
@@ -656,6 +656,7 @@ export const zhCN: TranslationResources = {
updateNoBase: "无法更新,因为无法确定 base branch",
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
mergePrMissing: "无法 merge PR,因为还没有 pull request",
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
@@ -678,9 +679,11 @@ export const zhCN: TranslationResources = {
baseRefUnavailable: "Base ref 不可用",
failedMerge: "Merge 失败",
failedMergeFromBase: "从 base merge 失败",
worktreePathUnavailable: "Worktree 路径不可用",
failedArchive: "归档 worktree 失败",
},
archiveWarning: {
title: "归档「{{workspaceName}}」?",
title: "归档「{{worktreeName}}」?",
confirm: "归档",
cancel: "取消",
uncommittedChanges: "未 commit 的变更",
@@ -828,7 +831,7 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
rename: "重命名 workspace",
archive: "归档",
archiveWorkspace: "归档工作区",
archiveWorktree: "归档 worktree",
hideFromSidebar: "从侧边栏隐藏",
archiving: "正在归档...",
hiding: "正在隐藏...",
@@ -850,7 +853,7 @@ export const zhCN: TranslationResources = {
branchNameCopied: "分支名称已复制",
hostDisconnected: "Host 未连接",
hideFailed: "隐藏 workspace 失败",
archiveFailed: "归档工作区失败",
archiveFailed: "归档 worktree 失败",
},
},
},
@@ -1583,7 +1586,7 @@ export const zhCN: TranslationResources = {
openProject: "打开项目",
newWorkspace: "新建 workspace",
newWorktree: "新建 worktree",
archiveWorkspace: "归档工作区",
archiveWorktree: "归档 worktree",
newTab: "新建标签",
closeCurrentTab: "关闭当前标签",
jumpToWorkspace: "跳转到 workspace",
+1 -1
View File
@@ -44,7 +44,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "workspace.new"
| "worktree.new"
| "workspace.archive"
| "worktree.archive"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";
@@ -29,7 +29,7 @@ export type KeyboardActionId =
| "sidebar.toggle.right"
| "workspace.new"
| "worktree.new"
| "workspace.archive";
| "worktree.archive";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope };
| { id: "worktree.archive"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
@@ -221,32 +221,28 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Archive workspace ---
// --- Archive worktree ---
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-cmd-shift-backspace-mac",
action: "workspace.archive",
action: "worktree.archive",
combo: "Cmd+Shift+Backspace",
when: { mac: true, commandCenter: false },
help: {
id: "archive-workspace",
id: "archive-worktree",
section: "projects",
label: "Archive workspace",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-ctrl-shift-backspace-non-mac",
action: "workspace.archive",
action: "worktree.archive",
combo: "Ctrl+Shift+Backspace",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-workspace",
id: "archive-worktree",
section: "projects",
label: "Archive workspace",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
@@ -30,7 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
+1 -1
View File
@@ -46,7 +46,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
@@ -1,26 +0,0 @@
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Stack } from "expo-router";
import { type ReactNode, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
interface ThemedStackBaseProps {
backgroundColor: string;
children?: ReactNode;
screenOptions?: NativeStackNavigationOptions;
}
function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) {
const themedScreenOptions = useMemo<NativeStackNavigationOptions>(
() => ({
...screenOptions,
contentStyle: [{ backgroundColor }, screenOptions?.contentStyle],
}),
[backgroundColor, screenOptions],
);
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
}
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
backgroundColor: theme.colors.surface0,
}));
@@ -77,7 +77,11 @@ function hasPendingArchiveForProject(input: {
}
}
return false;
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
}
export function useNewWorkspaceProjectPicker({
@@ -62,7 +62,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -3276,56 +3276,56 @@ function WorkspaceScreenContent({
hideLabels
/>
) : null}
{!isMobile && workspaceDirectory ? (
{!isMobile && isGitCheckout ? (
<>
<WorkspaceActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
{isGitCheckout ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
{workspaceDirectory ? (
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
{!isMobile && !isGitCheckout ? (
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
workspaceName: "feature/risky",
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
workspaceName: "feature/risky",
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
for (const workspace of workspaces) {
if (workspace.workspaceKind === "worktree") {
const shouldArchive = await confirmWorktreeArchive({
workspaceName: workspace.name,
worktreeName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
@@ -2,30 +2,20 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace(input);
}
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
@@ -44,6 +34,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
@@ -55,8 +46,37 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const archiveWorkspaceRecord = useCallback(async () => {
const archiveWorktreeRecord = useCallback(() => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId,
workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
});
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
const archiveNonWorktreeRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
@@ -64,18 +84,17 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
}
onSetHiding?.(true);
try {
onArchiveStarted();
await archiveWorkspaceOptimistically({
client,
workspace: {
serverId,
workspaceId,
},
afterHide: onArchiveStarted,
});
purgeArchivedWorkspaceState({ serverId, workspaceId });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
onSetHiding?.(false);
@@ -87,7 +106,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
workspaceName: name,
worktreeName: name,
isDirty,
aheadOfOrigin,
diffStat,
@@ -97,12 +116,15 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (!confirmed) {
return;
}
archiveWorktreeRecord();
return;
}
await archiveWorkspaceRecord();
await archiveNonWorktreeRecord();
})();
}, [
aheadOfOrigin,
archiveWorkspaceRecord,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
diffStat,
isDirty,
name,
@@ -144,6 +144,23 @@ describe("archiveWorkspaceOptimistically", () => {
}),
).toBe(false);
});
it("runs the after-hide hook after local state is hidden", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
const afterHide = vi.fn(() => {
expect(storedWorkspace(archived.id)).toBeUndefined();
});
await archiveWorkspaceOptimistically({
client,
workspace: target(),
afterHide,
});
expect(afterHide).toHaveBeenCalledOnce();
});
});
describe("archiveWorkspacesOptimistically", () => {
@@ -166,7 +183,10 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
});
expect(failures).toHaveLength(1);
@@ -199,10 +219,12 @@ describe("archiveWorkspacesOptimistically", () => {
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
@@ -9,6 +9,7 @@ import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -49,6 +50,7 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -81,8 +83,10 @@ async function archiveWorkspaceOrThrow(input: {
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
afterHide?: () => void;
}): Promise<void> {
const snapshot = hideWorkspaceOptimistically(input.workspace);
input.afterHide?.();
try {
await archiveWorkspaceOrThrow({
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.106",
"version": "0.1.105",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.105",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
-6
View File
@@ -17,7 +17,6 @@
* ```
*/
import { createCutoverProxy } from "./cutover-proxy.js";
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
type RelayProtocolVersion = "1" | "2";
@@ -98,7 +97,6 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
interface Env {
RELAY: DurableObjectNamespace;
PASEO_RELAY_UPSTREAM?: string;
}
interface DurableObjectNamespace {
@@ -574,10 +572,6 @@ export class RelayDurableObject {
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (env.PASEO_RELAY_UPSTREAM) {
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
}
const url = new URL(request.url);
// Health check
+7 -9
View File
@@ -2,9 +2,7 @@ import { createServer, type IncomingMessage, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import relayWorker from "./cloudflare-adapter.js";
type RelayEnv = Parameters<typeof relayWorker.fetch>[1];
import { createCutoverProxy } from "./cutover-proxy.js";
interface ReceivedRequest {
method: string;
@@ -66,19 +64,19 @@ describe("cutover proxy", () => {
const origin = new RecordingOrigin();
origins.push(origin);
const originUrl = await origin.start();
const proxy = createCutoverProxy(originUrl);
const response = await relayWorker.fetch(
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
headers: { "x-relay-probe": "production" },
const response = await proxy.fetch(
new Request("https://relay-staging.example/ws?serverId=srv_stage&role=server&v=2", {
headers: { "x-relay-probe": "staging" },
}),
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
);
expect(origin.received()).toEqual([
{
method: "GET",
path: "/ws?serverId=srv_prod&role=server&v=2",
probe: "production",
path: "/ws?serverId=srv_stage&role=server&v=2",
probe: "staging",
},
]);
expect(response.status).toBe(202);
+2
View File
@@ -15,3 +15,5 @@ export function createCutoverProxy(origin: string): CutoverProxy {
},
};
}
export default createCutoverProxy("https://paseo-relay-next.fly.dev");
+8
View File
@@ -0,0 +1,8 @@
name = "paseo-relay-cutover-staging"
main = "src/cutover-proxy.ts"
compatibility_date = "2024-12-01"
workers_dev = true
preview_urls = false
[observability]
enabled = true
-3
View File
@@ -8,9 +8,6 @@ routes = [{ pattern = "relay.paseo.sh", custom_domain = true }]
[observability]
enabled = true
[vars]
PASEO_RELAY_UPSTREAM = "https://paseo-relay-next.fly.dev"
[[durable_objects.bindings]]
name = "RELAY"
class_name = "RelayDurableObject"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -67,10 +67,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -30,7 +30,6 @@ import type {
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -378,148 +377,6 @@ afterEach(() => {
});
describe("target coalesced behavior", () => {
test("bounds tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([expectedItem]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds tool output by UTF-8 bytes", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
const expectedItem = toolCall({
status: "completed",
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds appended tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([expectedItem]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds tool output while hydrating provider history", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
await harness.manager.hydrateTimelineFromProvider(agentId);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds tool output emitted only to the live stream", async () => {
const harness = createHarness();
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.emitLiveTimelineItem(
agentId,
toolCall({ status: "completed", output }),
);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(await harness.manager.getTimelineRows(agentId)).toEqual([]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds failed shell output carried in error content and message", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "failed",
error: {
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
},
});
session.pushEvent(
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
);
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test(`coalesces a same-tick assistant burst after the ${COALESCE_WINDOW_MS}ms window`, async () => {
vi.useFakeTimers();
const harness = createHarness();
@@ -1,6 +1,6 @@
import { expect, test, vi } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -8,7 +8,6 @@ import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import {
AgentManager,
AgentManagerShuttingDownError,
commandMayHaveChangedExternalState,
type AgentManagerEvent,
type ManagedAgent,
@@ -140,117 +139,6 @@ class TestAgentClient implements AgentClient {
}
}
class HeldAgentCreationClient extends TestAgentClient {
private readonly creationStarted = deferred<void>();
private readonly creationAllowed = deferred<void>();
createdSessionClosed = false;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const recordSessionClosed = () => {
this.createdSessionClosed = true;
};
const session = new (class extends TestAgentSession {
override async close(): Promise<void> {
recordSessionClosed();
}
})(config);
this.creationStarted.resolve();
await this.creationAllowed.promise;
return session;
}
waitForCreationToStart(): Promise<void> {
return this.creationStarted.promise;
}
finishCreating(): void {
this.creationAllowed.resolve();
}
}
class HeldAgentCreationAndCloseClient extends TestAgentClient {
private readonly creationStarted = deferred<void>();
private readonly creationAllowed = deferred<void>();
private readonly closeStarted = deferred<void>();
private readonly closeAllowed = deferred<void>();
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.creationStarted.resolve();
await this.creationAllowed.promise;
const signalCloseStarted = () => this.closeStarted.resolve();
const waitForClose = () => this.closeAllowed.promise;
return new (class extends TestAgentSession {
override async close(): Promise<void> {
signalCloseStarted();
await waitForClose();
}
})(config);
}
waitForCreationToStart(): Promise<void> {
return this.creationStarted.promise;
}
finishCreating(): void {
this.creationAllowed.resolve();
}
waitForCloseToStart(): Promise<void> {
return this.closeStarted.promise;
}
finishClosing(): void {
this.closeAllowed.resolve();
}
}
class HeldReloadCloseClient extends TestAgentClient {
private readonly closeStarted = deferred<void>();
private readonly closeAllowed = deferred<void>();
originalSessionClosed = false;
replacementSessionClosed = false;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const signalCloseStarted = () => this.closeStarted.resolve();
const waitForClose = () => this.closeAllowed.promise;
const recordOriginalClosed = () => {
this.originalSessionClosed = true;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
signalCloseStarted();
await waitForClose();
recordOriginalClosed();
}
})(config);
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const recordReplacementClosed = () => {
this.replacementSessionClosed = true;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordReplacementClosed();
}
})({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
});
}
waitForCloseToStart(): Promise<void> {
return this.closeStarted.promise;
}
finishClosing(): void {
this.closeAllowed.resolve();
}
}
class NativeArchiveRecordingClient extends TestAgentClient {
readonly archivedHandles: AgentPersistenceHandle[] = [];
readonly unarchivedHandles: AgentPersistenceHandle[] = [];
@@ -402,52 +290,6 @@ class TestAgentSession implements AgentSession {
async close(): Promise<void> {}
}
class HeldRuntimeInfoSession extends TestAgentSession {
private readonly runtimeInfoRequested = deferred<void>();
private readonly runtimeInfoAllowed = deferred<void>();
override async getRuntimeInfo() {
this.runtimeInfoRequested.resolve();
await this.runtimeInfoAllowed.promise;
return await super.getRuntimeInfo();
}
waitForRuntimeInfo(): Promise<void> {
return this.runtimeInfoRequested.promise;
}
finishRuntimeInfo(): void {
this.runtimeInfoAllowed.resolve();
}
}
class HeldRuntimeInfoClient extends TestAgentClient {
private readonly sessionCreated = deferred<HeldRuntimeInfoSession>();
private session: HeldRuntimeInfoSession | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.session = new HeldRuntimeInfoSession(config);
this.sessionCreated.resolve(this.session);
return this.session;
}
async waitForRuntimeInfo(): Promise<void> {
const session = await this.sessionCreated.promise;
await session.waitForRuntimeInfo();
}
finishRuntimeInfo(): void {
this.requireSession().finishRuntimeInfo();
}
private requireSession(): HeldRuntimeInfoSession {
if (!this.session) {
throw new Error("Expected a created session");
}
return this.session;
}
}
class StreamingAssistantSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
@@ -607,211 +449,6 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
const logger = createTestLogger();
test("does not register a session that finishes starting after shutdown begins", async () => {
const client = new HeldAgentCreationClient();
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => "00000000-0000-4000-8000-000000000100",
});
const creation = manager.createAgent(
{
provider: "codex",
cwd: process.cwd(),
},
undefined,
{ workspaceId: undefined },
);
await client.waitForCreationToStart();
manager.prepareForShutdown();
client.finishCreating();
await expect(creation).rejects.toThrow("Agent manager is shutting down");
expect({ agents: manager.listAgents(), sessionClosed: client.createdSessionClosed }).toEqual({
agents: [],
sessionClosed: true,
});
});
test("flush waits for rejected session cleanup that starts after shutdown", async () => {
const client = new HeldAgentCreationAndCloseClient();
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => "00000000-0000-4000-8000-000000000098",
});
const creation = manager
.createAgent(
{
provider: "codex",
cwd: process.cwd(),
},
undefined,
{ workspaceId: undefined },
)
.catch((error: unknown) => error);
await client.waitForCreationToStart();
manager.prepareForShutdown();
let flushResolved = false;
const flushing = manager.flushForShutdown().then(() => {
flushResolved = true;
return undefined;
});
client.finishCreating();
await client.waitForCloseToStart();
try {
expect(flushResolved).toBe(false);
} finally {
client.finishClosing();
}
expect(await creation).toBeInstanceOf(AgentManagerShuttingDownError);
await flushing;
expect(manager.listAgents()).toEqual([]);
});
test("does not persist an initializing session after shutdown closes it", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-register-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldRuntimeInfoClient();
const agentId = "00000000-0000-4000-8000-000000000099";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
const creation = manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
await client.waitForRuntimeInfo();
manager.prepareForShutdown();
const closing = manager.closeAgent(agentId);
client.finishRuntimeInfo();
await expect(creation).rejects.toBeInstanceOf(AgentManagerShuttingDownError);
await closing;
await storage.flush();
expect({ agents: manager.listAgents(), record: await storage.get(agentId) }).toMatchObject({
agents: [],
record: { lastStatus: "closed" },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("reload leaves a closed durable snapshot when shutdown starts during the swap", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-reload-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldReloadCloseClient();
const agentId = "00000000-0000-4000-8000-000000000097";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
await client.waitForCloseToStart();
manager.prepareForShutdown();
const closing = Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
client.finishClosing();
await closing;
expect(await reload).toBeInstanceOf(AgentManagerShuttingDownError);
await manager.flush();
await storage.flush();
expect({
agents: manager.listAgents(),
record: await storage.get(agentId),
replacementSessionClosed: client.replacementSessionClosed,
}).toMatchObject({
agents: [],
record: { lastStatus: "closed" },
replacementSessionClosed: true,
});
} finally {
client.finishClosing();
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("reload closes both sessions when the closed snapshot cannot be persisted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new HeldReloadCloseClient();
const agentId = "00000000-0000-4000-8000-000000000096";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
await storage.flush();
rmSync(storagePath, { recursive: true, force: true });
writeFileSync(storagePath, "blocks the storage directory");
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
await client.waitForCloseToStart();
client.finishClosing();
expect(await reload).toBeInstanceOf(Error);
await manager.flushForShutdown();
expect({
agents: manager.listAgents(),
originalSessionClosed: client.originalSessionClosed,
replacementSessionClosed: client.replacementSessionClosed,
}).toEqual({
agents: [],
originalSessionClosed: true,
replacementSessionClosed: true,
});
} finally {
client.finishClosing();
await manager.flushForShutdown().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("normalizeConfig injects the provider default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -2314,22 +1951,6 @@ test("importProviderSession imports the selected session without listing and pub
item: { type: "assistant_message" as const, text: "Done" },
timestamp: "2026-01-02T00:00:01.000Z",
},
{
item: {
type: "tool_call" as const,
callId: "large-shell-result",
name: "shell",
status: "completed" as const,
error: null,
detail: {
type: "shell" as const,
command: "print output",
output: "x".repeat(1024 * 1024),
exitCode: 0,
},
},
timestamp: "2026-01-02T00:00:02.000Z",
},
],
};
}
@@ -2359,19 +1980,6 @@ test("importProviderSession imports the selected session without listing and pub
expect(manager.getTimeline(imported.id)).toEqual([
{ type: "user_message", text: "Trace provider imports" },
{ type: "assistant_message", text: "Done" },
{
type: "tool_call",
callId: "large-shell-result",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "print output",
output: "x".repeat(64 * 1024),
exitCode: 0,
},
},
]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
+93 -257
View File
@@ -58,7 +58,6 @@ import {
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
AgentStreamCoalescer,
} from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
@@ -83,13 +82,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
type TimeoutResult = "completed" | "timed_out";
export class AgentManagerShuttingDownError extends Error {
constructor() {
super("Agent manager is shutting down");
this.name = "AgentManagerShuttingDownError";
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
@@ -494,7 +486,7 @@ function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): A
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: limitAgentTimelineItemContent(entry.item),
item: entry.item,
});
}
return rows;
@@ -543,7 +535,6 @@ export class AgentManager {
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
@@ -555,7 +546,6 @@ export class AgentManager {
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
this.idFactory = options?.idFactory ?? (() => randomUUID());
@@ -629,10 +619,6 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
prepareForShutdown(): void {
this.acceptingAgentRegistrations = false;
}
setPaseoToolsEnabled(enabled: boolean): void {
this.paseoToolsEnabled = enabled;
}
@@ -936,20 +922,11 @@ export class AgentManager {
return this.timelineStore.fetch(id, options);
}
createAgent(
async createAgent(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
}
private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
this.requireEnabledProvider(storedConfig.provider);
@@ -977,7 +954,7 @@ export class AgentManager {
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
resumeAgentFromPersistence(
async resumeAgentFromPersistence(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
@@ -989,24 +966,6 @@ export class AgentManager {
workspaceId?: string;
},
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options),
);
}
private async resumeAgentFromPersistenceInternal(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
},
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
@@ -1035,24 +994,13 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
importProviderSession(input: {
async importProviderSession(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
}
private async importProviderSessionInternal(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider);
@@ -1077,30 +1025,20 @@ export class AgentManager {
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
handedToRegistration = true;
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(imported.session);
}
}
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
}
// Hot-reload an active agent session with config overrides. By default the
@@ -1109,22 +1047,11 @@ export class AgentManager {
// new epoch is minted and provider history is re-streamed — this is what the
// user-facing "Reload agent" action wants when the on-disk session was
// mutated outside Paseo.
reloadAgentSession(
async reloadAgentSession(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.reloadAgentSessionInternal(agentId, overrides, options),
);
}
private async reloadAgentSessionInternal(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
let existing = this.requireSessionAgent(agentId);
if (this.hasInFlightRun(agentId)) {
await this.cancelAgentRun(agentId);
@@ -1151,43 +1078,36 @@ export class AgentManager {
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
this.assertAcceptingAgentRegistrations();
const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
try {
await this.persistSnapshot(closedExisting);
} finally {
await this.closeReloadedSession(existing.session, agentId);
}
if (rehydrateFromDisk) {
// Wipe both durable and in-memory timeline so registerSession mints a
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
// provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(agentId);
}
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
});
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(session);
}
this.agentStreamCoalescer.flushAndDiscard(agentId);
// Remove the existing agent entry before swapping sessions
this.agents.delete(agentId);
if (existing.unsubscribeSession) {
existing.unsubscribeSession();
existing.unsubscribeSession = null;
}
this.foregroundRuns.clearAgent(agentId, existing);
await this.closeReloadedSession(existing.session, agentId);
if (rehydrateFromDisk) {
// Wipe both durable and in-memory timeline so registerSession mints a
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
// provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(agentId);
}
// Preserve existing labels and timeline during reload.
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
});
}
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
@@ -1753,7 +1673,6 @@ export class AgentManager {
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
item = limitAgentTimelineItemContent(item);
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agentId, item);
this.dispatchStream(
@@ -2555,84 +2474,49 @@ export class AgentManager {
workspaceId?: string;
},
): Promise<ManagedAgent> {
let registered = false;
try {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config,
options?.initialTitle ?? null,
);
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config,
options?.initialTitle ?? null,
);
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
agentId: resolvedAgentId,
now,
options,
});
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
agentId: resolvedAgentId,
now,
options,
});
const managed = this.buildManagedAgentForRegister({
resolvedAgentId,
session,
config,
now,
durableTimelineHasRows,
options,
});
const managed = this.buildManagedAgentForRegister({
resolvedAgentId,
session,
config,
now,
durableTimelineHasRows,
options,
});
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
});
this.assertAgentRegistrationActive(managed);
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
}
await this.refreshSessionState(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.assertAgentRegistrationActive(managed);
this.agents.set(resolvedAgentId, managed);
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
});
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
} catch (error) {
if (!registered) {
await this.closeUnregisteredSession(session);
}
throw error;
}
}
private assertAcceptingAgentRegistrations(): void {
if (!this.acceptingAgentRegistrations) {
throw new AgentManagerShuttingDownError();
}
}
private assertAgentRegistrationActive(agent: ActiveManagedAgent): void {
if (!this.acceptingAgentRegistrations || this.agents.get(agent.id) !== agent) {
throw new AgentManagerShuttingDownError();
}
}
private async closeUnregisteredSession(session: AgentSession): Promise<void> {
try {
await session.close();
} catch (error) {
this.logger.warn({ err: error }, "Failed to close unregistered agent session");
}
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
}
private async initializeAgentTimelineForRegister(params: {
@@ -2992,22 +2876,17 @@ export class AgentManager {
agent.historyPrimed = true;
for (const event of historyEvents) {
const item = limitAgentTimelineItemContent(event.item);
const row = this.recordTimeline(
agent.id,
item,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (options?.broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
@@ -3064,12 +2943,6 @@ export class AgentManager {
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
if (event.type === "timeline") {
event = {
...event,
item: limitAgentTimelineItemContent(event.item),
};
}
const eventTurnId = getAgentStreamEventTurnId(event);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
@@ -3566,7 +3439,6 @@ export class AgentManager {
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
item = limitAgentTimelineItemContent(item);
const row = this.timelineStore.append(agentId, item, options);
this.enqueueDurableTimelineAppend(agentId, row);
return row;
@@ -3693,45 +3565,15 @@ export class AgentManager {
});
}
private trackAgentRegistrationOperation<T>(result: Promise<T>): Promise<T> {
const settled = result.then(
() => undefined,
() => undefined,
);
this.agentRegistrationTasks.add(settled);
void settled.then(() => {
this.agentRegistrationTasks.delete(settled);
return undefined;
});
return result;
}
/**
* Flush any background persistence work (best-effort).
* Used by daemon shutdown paths to avoid unhandled rejections after cleanup.
*/
async flush(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: false });
}
/**
* Flush persistence and agent registrations that crossed the synchronous
* shutdown barrier. Those registrations own provider sessions until they
* either install them or close them.
*/
async flushForShutdown(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: true });
}
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
this.agentStreamCoalescer.flushAll();
// Drain tasks, including tasks spawned while awaiting.
while (
this.backgroundTasks.size > 0 ||
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
) {
const pending = options.includeAgentRegistrations
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
: [...this.backgroundTasks];
while (this.backgroundTasks.size > 0) {
const pending = Array.from(this.backgroundTasks);
await Promise.allSettled(pending);
}
}
@@ -3756,12 +3598,6 @@ export class AgentManager {
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string; timestamp?: string },
): void {
if (event.type === "timeline") {
event = {
...event,
item: limitAgentTimelineItemContent(event.item),
};
}
const agent = this.agents.get(agentId);
this.logger.trace(
{
@@ -1,67 +0,0 @@
import { StringDecoder } from "node:string_decoder";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
function limitTextContent(value: string): string {
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
return value;
}
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
return new StringDecoder("utf8").write(bytes);
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null
) {
return item;
}
const error: Record<string, unknown> = { ...item.error };
let changed = false;
for (const key of ["content", "message"] as const) {
const value = error[key];
if (typeof value !== "string") {
continue;
}
const limitedValue = limitTextContent(value);
if (limitedValue !== value) {
error[key] = limitedValue;
changed = true;
}
}
if (!changed) {
return item;
}
return {
...item,
error,
};
}
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
typeof item.detail.output !== "string"
) {
return item;
}
const output = limitTextContent(item.detail.output);
if (output === item.detail.output) {
return item;
}
return {
...item,
detail: {
...item.detail,
output,
},
};
}
@@ -1197,7 +1197,6 @@ describe("OpenCode adapter startTurn error handling", () => {
test("keeps a turn active while OpenCode is retrying", async () => {
vi.useFakeTimers();
const eventsGate = createTestDeferred<void>();
let eventStreamSignal: AbortSignal | undefined;
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
@@ -1223,27 +1222,14 @@ describe("OpenCode adapter startTurn error handling", () => {
},
};
}
return new Promise<IteratorResult<unknown>>((resolve) => {
if (eventStreamSignal?.aborted) {
resolve({ done: true, value: undefined });
return;
}
eventStreamSignal?.addEventListener(
"abort",
() => resolve({ done: true, value: undefined }),
{ once: true },
);
});
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
eventStreamSignal = signal;
return { stream: retryStream };
}),
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
@@ -1333,52 +1319,6 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(fakeClient.session.delete).not.toHaveBeenCalled();
});
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
const streamAborted = createTestDeferred<void>();
const finishStreamCleanup = createTestDeferred<void>();
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
stream: {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (!signal.aborted) {
await waitForAbort(signal);
}
streamAborted.resolve();
await finishStreamCleanup.promise;
return { done: true, value: undefined };
},
}),
},
})),
},
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockResolvedValue({ error: null }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
);
let closeSettled = false;
const closePromise = session.close().then(() => {
closeSettled = true;
return undefined;
});
await streamAborted.promise;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closeSettled).toBe(false);
finishStreamCleanup.resolve();
await closePromise;
});
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
const fakeClient = {
session: {
@@ -2238,15 +2178,6 @@ function createTestDeferred<T>(): {
return { promise, resolve, reject };
}
function waitForAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.resolve();
}
return new Promise((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
@@ -2789,7 +2789,6 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private eventStreamAbortController: AbortController | null = null;
private eventStreamReady: Deferred<void> | null = null;
private eventStreamTask: Promise<void> | null = null;
private suppressTerminalUntilNextUserMessage = false;
private closed = false;
private readonly persistSession: boolean;
@@ -3161,24 +3160,11 @@ class OpenCodeAgentSession implements AgentSession {
const eventStreamReady = createDeferred<void>();
this.eventStreamAbortController = eventStreamAbortController;
this.eventStreamReady = eventStreamReady;
const eventStreamTask = this.consumeEventStream(
eventStreamAbortController,
eventStreamReady,
).finally(() => {
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
if (this.eventStreamAbortController === eventStreamAbortController) {
this.eventStreamAbortController = null;
this.eventStreamReady = null;
}
if (this.eventStreamTask === eventStreamTask) {
this.eventStreamTask = null;
}
});
this.eventStreamTask = eventStreamTask;
void eventStreamTask.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode event stream task failed",
);
});
return eventStreamReady.promise;
@@ -3575,19 +3561,9 @@ class OpenCodeAgentSession implements AgentSession {
// unhandled rejection in whichever test the daemon hops to next.
this.closed = true;
this.abortController?.abort();
const eventStreamTask = this.eventStreamTask;
this.eventStreamAbortController?.abort();
if (eventStreamTask) {
await eventStreamTask.catch((error) => {
this.logger.debug(
{ err: error, sessionId: this.sessionId },
"OpenCode event stream failed during close",
);
});
}
this.eventStreamAbortController = null;
this.eventStreamReady = null;
this.eventStreamTask = null;
this.subscribers.clear();
await reconcileOpenCodeSessionClose({
client: this.client,
@@ -156,10 +156,7 @@ export class TestOpenCodeClient {
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
const signal = (options as { signal?: AbortSignal }).signal;
return {
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
};
return { stream: this.eventStream };
},
},
mcp: {
@@ -250,38 +247,6 @@ export class TestOpenCodeClient {
}
}
function stopEventStreamOnAbort(
stream: AsyncIterable<unknown>,
signal: AbortSignal,
): AsyncIterable<unknown> {
return {
[Symbol.asyncIterator]: () => {
const iterator = stream[Symbol.asyncIterator]();
return {
next: () => {
if (signal.aborted) {
return Promise.resolve({ done: true, value: undefined });
}
return new Promise<IteratorResult<unknown>>((resolve, reject) => {
const onAbort = () => resolve({ done: true, value: undefined });
signal.addEventListener("abort", onAbort, { once: true });
void iterator.next().then(
(result) => {
signal.removeEventListener("abort", onAbort);
return resolve(result);
},
(error) => {
signal.removeEventListener("abort", onAbort);
return reject(error);
},
);
});
},
};
},
};
}
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
return (async function* () {
for (const event of events) {
@@ -12,7 +12,7 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, onTestFinished, test } from "vitest";
import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
@@ -550,14 +550,12 @@ describe("PiRpcAgentSession", () => {
},
},
{},
{ env: { RESUME_PROBE: "expected" } },
);
expect(pi.recordedLaunches).toHaveLength(1);
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/workspace/project",
env: { RESUME_PROBE: "expected" },
session: "/tmp/native-pi-session",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
@@ -1223,21 +1221,7 @@ describe("PiRpcAgentClient", () => {
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
});
test("injects MCP servers without replacing the Pi global MCP config", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
writeFileSync(
path.join(agentDir, "mcp.json"),
JSON.stringify({
settings: { toolPrefix: "none", disableProxyTool: true },
"mcp-servers": {
"brave-search": {
url: "https://example.com/mcp/brave",
directTools: ["brave_llm_context"],
},
},
}),
);
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
{
@@ -1264,7 +1248,6 @@ describe("PiRpcAgentClient", () => {
},
},
}),
{ env: { PI_CODING_AGENT_DIR: agentDir } },
);
expect(pi.recordedLaunches).toHaveLength(2);
@@ -1293,12 +1276,7 @@ describe("PiRpcAgentClient", () => {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
settings: { toolPrefix: "none", disableProxyTool: true },
mcpServers: {
"brave-search": {
url: "https://example.com/mcp/brave",
directTools: ["brave_llm_context"],
},
paseo: {
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
auth: false,
@@ -1316,27 +1294,6 @@ describe("PiRpcAgentClient", () => {
expect(existsSync(configPath!)).toBe(false);
});
test("reports the path of a malformed Pi global MCP config", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
const configPath = path.join(agentDir, "mcp.json");
writeFileSync(configPath, "{ invalid");
const pi = new FakePi();
pi.queueCommands([{ name: "mcp", source: "extension" }]);
const client = createClient(pi);
await expect(
client.createSession(
createConfig({
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
},
}),
{ env: { PI_CODING_AGENT_DIR: agentDir } },
),
).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`);
});
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
const pi = new FakePi();
pi.queueCommands([]);
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
@@ -497,63 +497,14 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig {
};
}
function resolvePiAgentDir(env: Record<string, string> | undefined): string {
// Match pi-mcp-adapter's agent-directory resolution so we preserve the config it replaces.
const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
if (!configured) {
return join(homedir(), ".pi", "agent");
}
if (configured === "~") {
return homedir();
}
if (configured.startsWith("~/")) {
return resolvePath(homedir(), configured.slice(2));
}
return resolvePath(configured);
}
function createPiMcpConfigFile(
servers: Record<string, McpServerConfig>,
env: Record<string, string> | undefined,
): PiMcpConfigFile {
// pi-mcp-adapter treats --mcp-config as a replacement for its Pi global layer, not an
// additional layer. Rebuild that layer here; the adapter still loads shared and project files.
const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json");
let globalConfig: unknown = {};
if (existsSync(globalConfigPath)) {
const contents = readFileSync(globalConfigPath, "utf8");
try {
globalConfig = JSON.parse(contents) as unknown;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse Pi MCP config: ${globalConfigPath}`, { cause: error });
}
throw error;
}
}
if (!isRecord(globalConfig)) {
throw new Error(`Pi MCP config must contain a JSON object: ${globalConfigPath}`);
}
let configuredServers: Record<string, unknown> = {};
if (isRecord(globalConfig.mcpServers)) {
configuredServers = globalConfig.mcpServers;
} else if (isRecord(globalConfig["mcp-servers"])) {
configuredServers = globalConfig["mcp-servers"];
}
const mcpServers: Record<string, unknown> = { ...configuredServers };
function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpConfigFile {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
const filePath = join(dir, "mcp.json");
const mcpServers: Record<string, PiMcpServerConfig> = {};
for (const [name, serverConfig] of Object.entries(servers)) {
mcpServers[name] = toPiMcpConfig(serverConfig);
}
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
const filePath = join(dir, "mcp.json");
const mergedConfig: Record<string, unknown> = { ...globalConfig, mcpServers };
// Emit one canonical server key so a stale alias cannot shadow the injected definitions.
delete mergedConfig["mcp-servers"];
writeFileSync(filePath, `${JSON.stringify(mergedConfig, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8");
return {
path: filePath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
@@ -1960,10 +1911,7 @@ export class PiRpcAgentClient implements AgentClient {
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, {
...this.runtimeSettings?.env,
...launchContext?.env,
});
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
@@ -2005,7 +1953,7 @@ export class PiRpcAgentClient implements AgentClient {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2015,20 +1963,12 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
const mcpConfig = await this.prepareMcpConfig(
resumeConfig.cwd,
resumeConfig.config.mcpServers,
{
...this.runtimeSettings?.env,
...launchContext?.env,
},
);
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
cwd: resumeConfig.cwd,
env: launchContext?.env,
session: sessionFile,
model: resumeConfig.model,
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
@@ -2139,19 +2079,18 @@ export class PiRpcAgentClient implements AgentClient {
private async prepareMcpConfig(
cwd: string,
servers: Record<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined,
): Promise<PiMcpConfigFile | null> {
if (!servers || Object.keys(servers).length === 0) {
return null;
}
if (!(await this.detectMcpAdapter(cwd, env))) {
if (!(await this.detectMcpAdapter(cwd))) {
return null;
}
return createPiMcpConfigFile(servers, env);
return createPiMcpConfigFile(servers);
}
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => {
private async detectMcpAdapter(cwd: string): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start");
return null;
});
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { AgentManagerShuttingDownError } from "./agent/agent-manager.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
@@ -15,23 +14,6 @@ import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
interface HeldAgentClose {
started: Promise<void>;
arm(): void;
closeSession(): Promise<void>;
finish(): void;
}
interface BlockedDaemonShutdown {
probeReconnect(): Promise<WebSocketProbeResult>;
tryCreateAgent(): Promise<"created" | "rejected">;
finish(): Promise<void>;
}
type WebSocketProbeResult =
| { status: "connected" }
| { status: "rejected"; statusCode: number | null };
describe("paseo daemon bootstrap", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -224,17 +206,6 @@ describe("paseo daemon bootstrap", () => {
}
});
test("stops new connections and agent registrations before closing agents", async () => {
const shutdown = await beginDaemonShutdownWithAgentClosing();
try {
await expect(
Promise.all([shutdown.probeReconnect(), shutdown.tryCreateAgent()]),
).resolves.toEqual([{ status: "rejected", statusCode: 503 }, "rejected"]);
} finally {
await shutdown.finish();
}
});
test("standalone listener exposes services only", async () => {
const standalonePort = await findFreePort();
const upstream = http.createServer((_req, res) => {
@@ -551,97 +522,3 @@ describe("paseo daemon bootstrap", () => {
},
);
});
function holdAgentClose(): HeldAgentClose {
let armed = false;
let markStarted = () => {};
let finish = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
return {
started,
arm() {
armed = true;
},
async closeSession() {
if (!armed) {
return;
}
markStarted();
await finished;
},
finish: () => finish(),
};
}
async function beginDaemonShutdownWithAgentClosing(): Promise<BlockedDaemonShutdown> {
const heldAgentClose = holdAgentClose();
const daemonHandle = await createTestPaseoDaemon({
cleanup: false,
agentClients: createTestAgentClients({ closeSession: heldAgentClose.closeSession }),
});
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-shutdown-agent-"));
await daemonHandle.daemon.agentManager.createAgent(
{
provider: "codex",
cwd: agentCwd,
},
undefined,
{ workspaceId: undefined },
);
heldAgentClose.arm();
const stopPromise = daemonHandle.daemon.stop();
await heldAgentClose.started;
return {
probeReconnect: () => probeWebSocketConnection(`ws://127.0.0.1:${daemonHandle.port}/ws`),
async tryCreateAgent() {
try {
await daemonHandle.daemon.agentManager.createAgent(
{
provider: "codex",
cwd: agentCwd,
},
undefined,
{ workspaceId: undefined },
);
return "created";
} catch (error) {
if (error instanceof AgentManagerShuttingDownError) {
return "rejected";
}
throw error;
}
},
async finish() {
heldAgentClose.finish();
await stopPromise;
await daemonHandle.daemon.agentManager.flush().catch(() => undefined);
await Promise.all([
rm(path.dirname(daemonHandle.paseoHome), { recursive: true, force: true }),
rm(daemonHandle.staticDir, { recursive: true, force: true }),
rm(agentCwd, { recursive: true, force: true }),
]);
},
};
}
function probeWebSocketConnection(url: string): Promise<WebSocketProbeResult> {
const ws = new WebSocket(url);
return new Promise((resolve) => {
ws.once("open", () => {
ws.close();
resolve({ status: "connected" });
});
ws.once("error", () => resolve({ status: "rejected", statusCode: null }));
ws.once("unexpected-response", (_request, response) => {
response.resume();
resolve({ status: "rejected", statusCode: response.statusCode ?? null });
});
});
}
+1 -4
View File
@@ -1443,11 +1443,8 @@ export async function createPaseoDaemon(
const stop = async () => {
scriptHealthMonitor.stop();
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
agentManager.prepareForShutdown();
await closeAllAgents(logger, agentManager);
await agentManager.flushForShutdown().catch(() => undefined);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await providerSnapshotManager.shutdown();
@@ -51,18 +51,6 @@ interface Deferred<T> {
reject: (err: unknown) => void;
}
interface FakeAgentSessionOptions {
providerName: string;
config: AgentSessionConfig;
sessionId?: string;
memoryMarker?: string | null;
closeSession?: () => Promise<void>;
}
export interface TestAgentClientOptions {
closeSession?: () => Promise<void>;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
@@ -328,14 +316,16 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly closeSession: (() => Promise<void>) | undefined;
constructor(options: FakeAgentSessionOptions) {
this.providerName = options.providerName;
this.config = options.config;
this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null;
this.closeSession = options.closeSession;
constructor(
providerName: string,
config: AgentSessionConfig,
sessionId?: string,
memoryMarker?: string | null,
) {
this.providerName = providerName;
this.config = config;
this.id = sessionId ?? randomUUID();
this.memoryMarker = memoryMarker ?? null;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -857,9 +847,7 @@ class FakeAgentSession implements AgentSession {
this.interruptSignal.resolve();
}
async close(): Promise<void> {
await this.closeSession?.();
}
async close(): Promise<void> {}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.providerName === "codex") {
@@ -1167,20 +1155,13 @@ class FakeAgentSession implements AgentSession {
class FakeAgentClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
constructor(
public readonly provider: string,
private readonly options: TestAgentClientOptions,
) {}
constructor(public readonly provider: string) {}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
return new FakeAgentSession({
providerName: this.provider,
config: { ...config },
closeSession: this.options.closeSession,
});
return new FakeAgentSession(this.provider, { ...config });
}
async resumeSession(
@@ -1197,13 +1178,12 @@ class FakeAgentClient implements AgentClient {
(handle.metadata as Record<string, unknown> | undefined)?.marker ??
(handle.metadata as Record<string, unknown> | undefined)?.conversationId ??
null;
return new FakeAgentSession({
providerName: this.provider,
config: cfg,
sessionId: handle.sessionId,
memoryMarker: typeof marker === "string" ? marker : null,
closeSession: this.options.closeSession,
});
return new FakeAgentSession(
this.provider,
cfg,
handle.sessionId,
typeof marker === "string" ? marker : null,
);
}
async fetchCatalog(
@@ -1242,12 +1222,10 @@ class FakeAgentClient implements AgentClient {
}
}
export function createTestAgentClients(
options: TestAgentClientOptions = {},
): Record<string, AgentClient> {
export function createTestAgentClients(): Record<string, AgentClient> {
return {
claude: new FakeAgentClient("claude", options),
codex: new FakeAgentClient("codex", options),
opencode: new FakeAgentClient("opencode", options),
claude: new FakeAgentClient("claude"),
codex: new FakeAgentClient("codex"),
opencode: new FakeAgentClient("opencode"),
};
}
@@ -454,21 +454,6 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number])
};
}
function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
finish: () => void;
} {
let finish = () => {};
session.cleanup.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
return {
finish: () => finish(),
};
}
describe("relay external socket reconnect behavior", () => {
beforeEach(() => {
sessionMock.instances.length = 0;
@@ -532,36 +517,6 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("rejects sockets attached after shutdown begins", async () => {
const server = createServer();
const existingSocket = new MockSocket();
await attachRelayAndHello({
server,
socket: existingSocket,
clientId: "existing-client",
});
const heldCleanup = holdSessionCleanup(sessionMock.instances[0]);
const closePromise = server.close();
const lateSocket = new MockSocket();
try {
await server.attachExternalSocket(lateSocket, { transport: "relay" });
lateSocket.emit("message", JSON.stringify(createHelloMessage("late-client")));
expect({
readyState: lateSocket.readyState,
sessionCount: sessionMock.instances.length,
}).toEqual({
readyState: 3,
sessionCount: 1,
});
} finally {
heldCleanup.finish();
await closePromise;
}
});
test("closes pending connection when hello timeout elapses", async () => {
const server = createServer();
@@ -351,7 +351,6 @@ const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
const WS_CLOSE_INVALID_HELLO = 4002;
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_CLOSE_SERVER_SHUTDOWN = 1001;
const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
@@ -464,7 +463,6 @@ export class VoiceAssistantWebSocketServer {
private unsubscribeTerminalActivity: (() => void) | null = null;
private readonly browserToolsBroker: BrowserToolsBroker | null;
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
private acceptingConnections = true;
constructor(
server: HTTPServer,
@@ -710,11 +708,6 @@ export class VoiceAssistantWebSocketServer {
hostnames: HostnamesConfig | undefined,
callback: (res: boolean, code?: number, message?: string) => void,
): void {
if (!this.acceptingConnections) {
callback(false, 503, "Server shutting down");
return;
}
const requestMetadata = extractSocketRequestMetadata(req);
const origin = requestMetadata.origin;
const requestHost = requestMetadata.host ?? null;
@@ -806,12 +799,7 @@ export class VoiceAssistantWebSocketServer {
await this.attachSocket(ws, undefined, metadata);
}
public prepareForShutdown(): void {
this.acceptingConnections = false;
}
public async close(): Promise<void> {
this.prepareForShutdown();
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
this.unsubscribeDaemonConfigChange?.();
@@ -933,15 +921,6 @@ export class VoiceAssistantWebSocketServer {
request?: unknown,
metadata?: ExternalSocketMetadata,
): Promise<void> {
if (!this.acceptingConnections) {
try {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
} catch {
// ignore close errors
}
return;
}
const requestMetadata = extractSocketRequestMetadata(request);
const identity = createWebSocketConnectionIdentity(requestMetadata, metadata);
this.socketIdentities.set(ws, identity);
@@ -1630,10 +1609,6 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike,
data: Buffer | ArrayBuffer | Buffer[] | string,
): void {
if (!this.acceptingConnections) {
return;
}
const activeConnection = this.sessions.get(ws);
const pendingConnection = this.pendingConnections.get(ws);
const log =
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"type": "module",
"scripts": {