Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 3448e8b023 Keep agent tools bound to active sessions 2026-06-08 14:47:39 +07:00
Mohamed Boudra 6524677960 Document checkout-local dev home defaults 2026-06-08 10:34:54 +07:00
Mohamed Boudra 09a1fe46fe Hide OpenCode compaction summaries 2026-06-08 10:14:54 +07:00
Mohamed Boudra 06a8f952db Keep Paseo MCP config out of agent storage
Runtime-injected MCP endpoints can change with daemon listen settings, so compute them for launches instead of persisting them.
2026-06-07 23:31:03 +07:00
Mohamed Boudra 893e3376b0 Recover stale supervised daemons
Teach daemon stop to use lifecycle shutdown when the API is reachable even if the owner pid is stale, then wait for the API to disappear and clean the stale pidfile.

Launch desktop-managed daemons detached from desktop stdio, preserve stale reachable daemon ownership for version checks, and allow desktop stop/restart to use the CLI recovery path.

Add supervisor heartbeats so supervised workers shut down when their supervisor disappears instead of surviving as orphaned reachable daemons.
2026-06-07 23:18:48 +07:00
Mohamed Boudra 7c8b290e2f Stabilize local dev daemon homes
Use a checkout-local .dev/paseo-home for root and worktree dev flows so development daemons do not collide with the packaged app home.

Split server, app, and desktop dev entrypoints, seed worktree homes from the source checkout metadata, and keep desktop dev on its own user-data directory and Expo port.
2026-06-07 23:18:32 +07:00
Mohamed Boudra d55e1622cb Persist create-agent mode preferences reliably 2026-06-07 15:53:17 +07:00
Mohamed Boudra 69715a77e9 Disable pull and push until the branch diverges 2026-06-07 14:21:32 +07:00
Mohamed Boudra e1b27fc584 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-07 14:07:45 +07:00
Mohamed Boudra 9c16bc474b Add command center to mobile
Renders as a bottom sheet on compact native instead of returning null.
The hook's window.addEventListener keydown is gated behind isWeb; native
uses TextInput onKeyPress and onSubmitEditing through a shared
handleKeyEvent callback.
2026-06-07 14:07:14 +07:00
59d48d235a Fix Pi compaction slash commands (#1338)
* fix: handle Pi compact slash command

* fix: handle Pi compaction slash commands

* fix: harden Pi compaction commands

* fix: handle missing Pi autocompaction state

---------

Co-authored-by: X <X@16x.org>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-06 22:41:08 +08:00
Mohamed Boudra 6aa73baaab Show useful local speech crash details (#1379)
* Improve local speech worker crash diagnostics

* Fix speech worker stderr tail diagnostics

* Drain speech worker stderr before reporting exit

* Avoid false speech worker crash logs on shutdown
2026-06-06 21:45:33 +08:00
Mohamed Boudra 0cf1717e04 Fix auto-archive after merged PR branch deletion (#1378) 2026-06-06 21:13:16 +08:00
Mohamed Boudra 2822c02543 Keep Codex request identity vanilla (#1377) 2026-06-06 21:00:25 +08:00
Mohamed Boudra db4376d17a Prepare prompt file attachments for future UI (#1376)
* Support file uploads to agents

* Harden prompt file upload handling

* Fix duplicate upload retry handling

* Update relay reconnect tests for queued messages

* Make upload timeout track idle progress

* Clean failed upload directories
2026-06-06 20:37:38 +08:00
Mohamed Boudra 9a8912b3ef Open browser links in workspace tabs (#1375)
* Open browser window requests as workspace tabs

* Tighten browser new-tab request boundaries
2026-06-06 19:30:21 +08:00
paseo-ai[bot] fd022bc44b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-05 16:13:31 +00:00
96 changed files with 8422 additions and 3261 deletions
+1
View File
@@ -64,6 +64,7 @@ CLAUDE.local.md
.debug.conversations/
.debug/
.dev/
.paseo/
.wrangler/
**/.wrangler/
+1
View File
@@ -3,6 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "error",
+5 -1
View File
@@ -49,7 +49,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
## Quick start
```bash
npm run dev # Start daemon + Expo in Tmux
npm run dev # Start the dev daemon
npm run dev:app # Start Expo against the dev daemon
npm run dev:desktop # Start Electron desktop dev
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
@@ -58,6 +60,8 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
+23 -11
View File
@@ -8,18 +8,27 @@
## Running the dev server
```bash
npm run dev
npm run dev:server
npm run dev:app
npm run dev:desktop
```
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
Root checkout dev is intentionally split across terminals:
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
### PASEO_HOME
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
Override knobs:
@@ -32,16 +41,19 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
### Daemon endpoints
- Stable daemon launched by the desktop app: `localhost:6767`.
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
- Root checkout dev daemon: `localhost:6768`.
- Root checkout Expo: `http://localhost:8081`.
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
- `npm run dev` (Windows): `localhost:6767` for the daemon.
In any worktree-style or portless setup, never assume default ports.
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
It launches its own Electron-flavored Expo server and passes that URL to Electron.
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
@@ -185,7 +197,7 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
Use the named root build targets instead of remembering workspace dependency chains:
@@ -223,7 +235,7 @@ install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
```bash
npm run cli -- ls -a -g # List all agents globally
@@ -277,7 +289,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
## Testing with Playwright MCP
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
+1 -1
View File
@@ -1 +1 @@
sha256-U04ZysdAC9Qf8GXyBjbsz6Eu87jpJU8iV/RCXbkFlBI=
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
+5 -5
View File
@@ -34,12 +34,12 @@
"packages/cli"
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev": "npm run dev:server",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
@@ -82,11 +82,11 @@
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { createDefaultLayout } from "@/stores/workspace-layout-store";
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
return openTabInLayoutFocused({
layout: createDefaultLayout(),
target: { kind: "browser", browserId },
now: 1,
}).layout;
}
describe("browser new-tab requests", () => {
it("accepts desktop requests from browser tabs in the current workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toEqual<BrowserNewTabRequest>({
sourceBrowserId: "browser-1",
url: "https://example.com/target",
});
});
it("ignores desktop requests from another workspace", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-from-other-workspace",
url: "https://example.com/target",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
it("rejects unsupported desktop request URLs", () => {
const request = resolveBrowserNewTabRequest({
payload: {
sourceBrowserId: "browser-1",
url: "file:///etc/passwd",
},
workspaceLayout: createLayoutWithBrowser("browser-1"),
});
expect(request).toBeNull();
});
});
@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import { getIsElectron } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
function isAllowedBrowserNewTabUrl(value: string): boolean {
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
if (!payload || typeof payload !== "object") {
return null;
}
const candidate = payload as Partial<BrowserNewTabRequest>;
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
return null;
}
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
return null;
}
return {
sourceBrowserId: candidate.sourceBrowserId,
url: candidate.url,
};
}
function workspaceContainsBrowser(input: {
workspaceLayout: WorkspaceLayout | null | undefined;
browserId: string;
}): boolean {
if (!input.workspaceLayout) {
return false;
}
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
});
}
export function resolveBrowserNewTabRequest(input: {
payload: unknown;
workspaceLayout: WorkspaceLayout | null | undefined;
}): BrowserNewTabRequest | null {
const request = readDesktopBrowserNewTabRequest(input.payload);
if (!request) {
return null;
}
if (
!workspaceContainsBrowser({
workspaceLayout: input.workspaceLayout,
browserId: request.sourceBrowserId,
})
) {
return null;
}
return request;
}
export function useDesktopBrowserNewTabRequests(input: {
enabled: boolean;
workspaceLayout: WorkspaceLayout | null | undefined;
openUrl: (url: string) => void;
}): void {
const handleNewTabRequest = useStableEvent((payload: unknown) => {
const request = resolveBrowserNewTabRequest({
payload,
workspaceLayout: input.workspaceLayout,
});
if (!request) {
return;
}
input.openUrl(request.url);
});
useEffect(() => {
if (!input.enabled || !getIsElectron()) {
return;
}
const unsubscribe = getDesktopHost()?.events?.on?.(
"browser-new-tab-request",
handleNewTabRequest,
);
if (typeof unsubscribe === "function") {
return unsubscribe;
}
return () => {
void unsubscribe?.then((dispose) => dispose());
};
}, [handleNewTabRequest, input.enabled]);
}
+237 -67
View File
@@ -9,24 +9,39 @@ import {
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
} from "@gorhom/bottom-sheet";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
}
const CommandCenterRow = memo(function CommandCenterRow({
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
children,
onPress,
registerRow,
onLayout,
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
);
return (
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
{children}
</Pressable>
);
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode;
}
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
active,
rowRefs,
onPress,
onLayout,
children,
}: CommandCenterRowContainerProps) {
const registerRow = useCallback(
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
[rowRefs, rowIndex],
);
return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={onPress}
onLayout={onLayout}
>
{children}
</CommandCenterRow>
);
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
}
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
item,
children,
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRowContainer>
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
actionItemsLength: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
@@ -219,6 +251,7 @@ function AgentItemsSection({
actionItemsLength,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
@@ -237,6 +270,7 @@ function AgentItemsSection({
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
@@ -249,50 +283,119 @@ function AgentItemsSection({
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null);
const nativeScrollY = useRef(0);
const nativeViewHeight = useRef(0);
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bottomSheetInputRef = useRef<any>(null);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: showBottomSheet,
onClose: handleClose,
});
// Focus the bottom sheet input when the sheet opens on mobile
useEffect(() => {
if (!open) {
return;
if (showBottomSheet && open) {
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
return () => clearTimeout(id);
}
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") {
return;
}
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
}, [showBottomSheet, open]);
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
// Scroll active row into view
useEffect(() => {
if (!open) return;
if (isWeb) {
const row = rowRefs.current.get(activeIndex);
if (!row || typeof document === "undefined") return;
const scrollNode =
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
rowEl.scrollIntoView?.({ block: "nearest" });
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
}
return;
}
const rowTop = rowEl.offsetTop;
const rowBottom = rowTop + rowEl.offsetHeight;
const visibleTop = scrollNode.scrollTop;
const visibleBottom = visibleTop + scrollNode.clientHeight;
// Native: use onLayout-measured positions
const layout = rowLayouts.current.get(activeIndex);
if (!layout || !resultsRef.current) return;
const rowTop = layout.y;
const rowBottom = rowTop + layout.height;
const visibleTop = nativeScrollY.current;
const visibleBottom = visibleTop + nativeViewHeight.current;
if (rowTop < visibleTop) {
scrollNode.scrollTop = rowTop;
return;
}
if (rowBottom > visibleBottom) {
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
} else if (rowBottom > visibleBottom) {
resultsRef.current.scrollTo?.({
y: rowBottom - nativeViewHeight.current,
animated: true,
});
}
}, [activeIndex, open]);
const handleRowLayout = useCallback(
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
rowLayouts.current.set(rowIndex, {
y: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
},
[],
);
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
@@ -324,8 +427,101 @@ export function CommandCenter() {
[theme.colors.border],
);
if (isNative || !open) return null;
const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
handleKeyEvent(key);
},
[handleKeyEvent],
);
const handleSubmitEditing = useCallback(() => {
handleKeyEvent("Enter");
}, [handleKeyEvent]);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onLayout={handleRowLayout(index)}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
);
// Mobile: bottom sheet
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
ref={sheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef as unknown as React.Ref<never>}
value={query}
onChangeText={setQuery}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmitEditing}
placeholder="Type a command or search agents..."
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{resultList}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
);
}
if (!open) return null;
// Desktop web: centered overlay panel
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
@@ -354,39 +550,7 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{items.length === 0 ? (
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
)}
{resultList}
</ScrollView>
</View>
</View>
@@ -395,6 +559,12 @@ export function CommandCenter() {
}
const styles = StyleSheet.create((theme) => ({
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
overlay: {
flex: 1,
justifyContent: "flex-start",
@@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
@@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
onBeforeSubmit?.({
await onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,
@@ -441,8 +441,8 @@ export function WorkspaceDraftAgentTab({
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
}),
onBeforeSubmit: () => {
void composerState.persistFormPreferences();
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { CreateAgentPreferencesService } from "./service";
import {
mergeCreateAgentSelectionPreferences,
mergeProviderPreferences,
parseFormPreferences,
} from "./preferences";
import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage";
describe("create agent preferences", () => {
it("keeps the selected mode after saving model and thinking", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const modelWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } },
}),
);
await storage.nextWrite();
const modeWrite = preferences.update((current) =>
mergeProviderPreferences({
preferences: current,
provider: "codex",
updates: { mode: "full-access" },
}),
);
expect(storage.pendingWriteCount()).toBe(1);
storage.finishOldestWrite();
await modelWrite;
await storage.nextWrite();
storage.finishOldestWrite();
await modeWrite;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
thinkingByModel: { "gpt-5.5": "high" },
mode: "full-access",
},
},
});
});
it("flushes the full create-agent selection into provider preferences", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const saveSelection = preferences.update((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: "codex",
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
await storage.nextWrite();
storage.finishOldestWrite();
await saveSelection;
expect(storage.savedPreferences()).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
featureValues: { fast_mode: true },
},
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
});
@@ -0,0 +1,147 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): FormPreferences {
const result = formPreferencesSchema.safeParse(value);
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function mergeCreateAgentSelectionPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider | null;
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
}
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: {
model: modelId || undefined,
mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(args.featureValues ? { featureValues: args.featureValues } : {}),
},
});
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}
@@ -0,0 +1,62 @@
import { AsyncStorageCreateAgentPreferenceStorage } from "./storage";
import {
DEFAULT_FORM_PREFERENCES,
parseFormPreferences,
type FormPreferences,
} from "./preferences";
import type { CreateAgentPreferenceStorage } from "./storage";
export type FormPreferenceUpdate =
| Partial<FormPreferences>
| ((current: FormPreferences) => FormPreferences);
export class CreateAgentPreferencesService {
private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES;
private isLoaded = false;
private loadPromise: Promise<FormPreferences> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: CreateAgentPreferenceStorage) {}
async load(): Promise<FormPreferences> {
if (this.isLoaded) {
return this.preferences;
}
if (!this.loadPromise) {
this.loadPromise = this.storage.read().then((stored) => {
this.preferences = parseFormPreferences(stored);
this.isLoaded = true;
return this.preferences;
});
}
return this.loadPromise;
}
async update(update: FormPreferenceUpdate): Promise<FormPreferences> {
const previousWrite = this.writeQueue;
const operation = this.applyQueuedUpdate(previousWrite, update);
this.writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
private async applyQueuedUpdate(
previousWrite: Promise<void>,
update: FormPreferenceUpdate,
): Promise<FormPreferences> {
await previousWrite;
const current = await this.load();
const next = typeof update === "function" ? update(current) : { ...current, ...update };
this.preferences = parseFormPreferences(next);
this.isLoaded = true;
await this.storage.write(this.preferences);
return this.preferences;
}
}
export const createAgentPreferencesService = new CreateAgentPreferencesService(
new AsyncStorageCreateAgentPreferenceStorage(),
);
@@ -0,0 +1,28 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { FormPreferences } from "./preferences";
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
export interface CreateAgentPreferenceStorage {
read(): Promise<unknown>;
write(preferences: FormPreferences): Promise<void>;
}
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> {
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
if (!stored) {
return null;
}
try {
return JSON.parse(stored);
} catch {
return null;
}
}
async write(preferences: FormPreferences): Promise<void> {
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
}
}
@@ -0,0 +1,65 @@
import type { FormPreferences } from "../preferences";
import type { CreateAgentPreferenceStorage } from "../storage";
interface PendingWrite {
preferences: FormPreferences;
finish: () => void;
}
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
private stored: unknown;
private readonly pendingWrites: PendingWrite[] = [];
private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = [];
constructor(input: { stored?: unknown } = {}) {
this.stored = input.stored ?? null;
}
async read(): Promise<unknown> {
return this.stored;
}
write(preferences: FormPreferences): Promise<void> {
return new Promise((resolve) => {
const write = {
preferences,
finish: () => {
this.stored = clone(preferences);
resolve();
},
};
this.pendingWrites.push(write);
this.pendingWriteWaiters.shift()?.(write);
});
}
nextWrite(): Promise<PendingWrite> {
const next = this.pendingWrites[0];
if (next) {
return Promise.resolve(next);
}
return new Promise((resolve) => {
this.pendingWriteWaiters.push(resolve);
});
}
pendingWriteCount(): number {
return this.pendingWrites.length;
}
finishOldestWrite(): void {
const write = this.pendingWrites.shift();
if (!write) {
throw new Error("No pending create-agent preference write");
}
write.finish();
}
savedPreferences(): unknown {
return this.stored;
}
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
+5
View File
@@ -111,6 +111,11 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
url: string;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
+32
View File
@@ -252,6 +252,38 @@ describe("git-actions-policy", () => {
});
});
it("keeps pull-and-push unavailable when the branch only has outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 0,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("keeps pull-and-push unavailable when the branch only has incoming commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
unavailableMessage: expect.any(String),
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
+6
View File
@@ -616,6 +616,12 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if (input.aheadOfOrigin === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
}
return undefined;
}
+32 -16
View File
@@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -319,33 +320,25 @@ export function useCommandCenter() {
}
}, [activeIndex, items.length, open]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
const currentItems = itemsRef.current;
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
if (key === "Escape") {
event.preventDefault();
handleCloseRef.current();
return;
return true;
}
if (key === "Enter") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
handleSelectItemRef.current(currentItems[index]);
return;
return true;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (currentItems.length === 0) return;
event.preventDefault();
if (currentItems.length === 0) return false;
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
@@ -353,13 +346,35 @@ export function useCommandCenter() {
if (next >= currentItems.length) return 0;
return next;
});
return true;
}
return false;
},
[open],
);
useEffect(() => {
if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (handleKeyEvent(event.key)) {
event.preventDefault();
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open]);
}, [open, handleKeyEvent]);
return {
open,
@@ -371,5 +386,6 @@ export function useCommandCenter() {
items,
handleClose,
handleSelectItem,
handleKeyEvent,
};
}
+21 -127
View File
@@ -1,137 +1,35 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import {
buildFavoriteModelKey,
DEFAULT_FORM_PREFERENCES,
isFavoriteModel,
mergeProviderPreferences,
toggleFavoriteModel,
type FavoriteModelPreference,
type FavoriteModelRow,
type FormPreferences,
type ProviderPreferences,
} from "@/create-agent-preferences/preferences";
import {
createAgentPreferencesService,
type FormPreferenceUpdate,
} from "@/create-agent-preferences/service";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences };
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
)
.optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel };
async function loadFormPreferences(): Promise<FormPreferences> {
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
if (!stored) return DEFAULT_FORM_PREFERENCES;
const result = formPreferencesSchema.safeParse(JSON.parse(stored));
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
return createAgentPreferencesService.load();
}
export interface UseFormPreferencesReturn {
preferences: FormPreferences;
isLoading: boolean;
updatePreferences: (
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
},
};
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
}
export function useFormPreferences(): UseFormPreferencesReturn {
@@ -146,13 +44,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
const updatePreferences = useCallback(
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
const prev =
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
DEFAULT_FORM_PREFERENCES;
const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
async (updates: FormPreferenceUpdate) => {
const next = await createAgentPreferencesService.update(updates);
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
@@ -747,9 +747,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
provider,
clientMessageId,
timestamp,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
@@ -1063,6 +1061,7 @@ export function NewWorkspaceScreen({
async (payload: MessagePayload) => {
try {
setErrorMessage(null);
await composerState?.persistFormPreferences();
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
await runCreateEmptyWorkspace({
@@ -130,6 +130,7 @@ import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuEntry,
} from "@/screens/workspace/workspace-tab-menu";
import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
resolveWorkspaceHeaderRenderState,
@@ -2329,6 +2330,12 @@ function WorkspaceScreenContent({
[openWorkspaceTabFocused, persistenceKey],
);
useDesktopBrowserNewTabRequests({
enabled: Boolean(persistenceKey),
workspaceLayout,
openUrl: handleOpenUrlInBrowserTab,
});
const handleSelectSwitcherTab = useCallback(
(key: string) => {
navigateToTabId(key);
+36
View File
@@ -99,6 +99,21 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
};
}
function compactionTimeline(
status: "loading" | "completed",
trigger?: "auto" | "manual",
): AgentStreamEventPayload {
return {
type: "timeline",
provider: "pi",
item: {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
},
};
}
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
return state.find(
(item): item is AgentToolCallItem =>
@@ -693,6 +708,27 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[1]?.completed, true);
});
it("preserves compaction trigger when completed update replaces loading marker", () => {
const state = hydrateStreamState([
{
event: compactionTimeline("loading", "auto"),
timestamp: new Date("2025-01-01T10:50:00Z"),
},
{
event: compactionTimeline("completed"),
timestamp: new Date("2025-01-01T10:50:01Z"),
},
]);
const compactions = state.filter(
(item): item is Extract<StreamItem, { kind: "compaction" }> => item.kind === "compaction",
);
assert.strictEqual(compactions.length, 1);
assert.strictEqual(compactions[0].status, "completed");
assert.strictEqual(compactions[0].trigger, "auto");
});
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
const state = hydrateStreamState([
{
+2 -2
View File
@@ -726,8 +726,8 @@ function reduceTimelineCompaction(
const updated: CompactionItem = {
...existing,
status: "completed",
trigger: item.trigger,
preTokens: item.preTokens,
trigger: item.trigger ?? existing.trigger,
preTokens: item.preTokens ?? existing.preTokens,
};
return [...state.slice(0, loadingIdx), updated, ...state.slice(loadingIdx + 1)];
}
+147 -38
View File
@@ -1,5 +1,5 @@
import { spawnSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
@@ -351,6 +351,123 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
return poll();
}
async function waitForDaemonUnreachable(
state: LocalDaemonState,
timeoutMs: number,
): Promise<boolean> {
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return true;
}
const reachableHost = host;
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
if (!client) {
return true;
}
await client.close().catch(() => undefined);
if (Date.now() >= deadline) {
const finalClient = await tryConnectToDaemon({
host: reachableHost,
timeout: PID_POLL_INTERVAL_MS,
});
if (!finalClient) {
return true;
}
await finalClient.close().catch(() => undefined);
return false;
}
await sleep(PID_POLL_INTERVAL_MS);
return poll();
}
return poll();
}
function removeStalePidFile(state: LocalDaemonState): void {
if (!state.stalePidFile) {
return;
}
try {
unlinkSync(state.pidPath);
} catch {
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
}
}
function createNotRunningStopResult(
state: LocalDaemonState,
pid: number | null,
message: string,
): StopLocalDaemonResult {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message,
};
}
function createStopTimeoutError(
state: LocalDaemonState,
pid: number | null,
timeoutMs: number,
): Error {
if (!state.running) {
const host = resolveTcpHostFromListen(state.listen);
return new Error(
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
timeoutMs / 1000,
)}s`,
);
}
return new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
async function signalDaemonOwnerForStop(
state: LocalDaemonState,
pid: number | null,
): Promise<StopLocalDaemonResult | null> {
if (pid === null) {
return createNotRunningStopResult(state, null, "Daemon is not running");
}
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (signaled) {
return null;
}
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
}
async function waitForStopAfterRequest(args: {
state: LocalDaemonState;
pid: number | null;
timeoutMs: number;
killTimeoutMs: number;
force?: boolean;
}): Promise<{ stopped: boolean; forced: boolean }> {
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
let stopped =
state.running && pid !== null
? await waitForPidExit(pid, timeoutMs)
: await waitForDaemonUnreachable(state, timeoutMs);
if (!stopped && force && state.running && pid !== null) {
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
return { stopped, forced: true };
}
return { stopped, forced: false };
}
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
@@ -567,49 +684,41 @@ export async function stopLocalDaemon(
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return {
action: "not_running",
home: state.home,
pid: state.pidInfo?.pid ?? null,
forced: false,
message: `Daemon is not running${staleSuffix}`,
};
}
const pid = state.pidInfo.pid;
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const lifecycleRequested = shutdownAttempt.requested;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
let forced = false;
if (!lifecycleRequested) {
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
if (!signaled) {
return {
action: "not_running",
home: state.home,
pid,
forced: false,
message: "Daemon process was already stopped",
};
}
}
let stopped = await waitForPidExit(pid, timeoutMs);
if (!stopped && options.force) {
forced = true;
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, killTimeoutMs);
}
if (!stopped) {
throw new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
const staleSuffix =
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return createNotRunningStopResult(
state,
state.pidInfo?.pid ?? null,
`Daemon is not running${staleSuffix}`,
);
}
const pid = state.pidInfo?.pid ?? null;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
if (!lifecycleRequested) {
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
if (notRunningResult) return notRunningResult;
}
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs,
killTimeoutMs,
force: options.force,
});
if (!stopped) {
throw createStopTimeoutError(state, pid, timeoutMs);
}
if (lifecycleRequested) {
removeStalePidFile(state);
}
return {
action: "stopped",
home: state.home,
@@ -0,0 +1,207 @@
#!/usr/bin/env npx tsx
/**
* Regression: `paseo daemon stop` must stop a reachable daemon even when the
* local pid file points at a dead supervisor owner.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false;
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface DaemonStatus {
localDaemon: string | null;
connectedDaemon: string | null;
pid: number | null;
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { localDaemon: null, connectedDaemon: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as {
localDaemon?: unknown;
connectedDaemon?: unknown;
pid?: unknown;
};
return {
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
connectedDaemon: typeof parsed.connectedDaemon === "string" ? parsed.connectedDaemon : null,
pid:
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null,
};
} catch {
return { localDaemon: null, connectedDaemon: null, pid: null };
}
}
function findUnusedPid(): number {
for (let pid = 999_999; pid > 900_000; pid--) {
if (!isProcessRunning(pid)) {
return pid;
}
}
throw new Error("Unable to find unused pid for stale pid fixture");
}
console.log("=== Daemon Stop (stale pid, reachable worker regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-stale-reachable-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
const pidPath = join(paseoHome, "paseo.pid");
const stalePid = findUnusedPid();
let workerProcess: ChildProcess | null = null;
try {
console.log("Test 1: start daemon worker with stale supervisor pid file");
await writeFile(
pidPath,
`${JSON.stringify(
{
pid: stalePid,
startedAt: new Date().toISOString(),
hostname: "stale-supervisor-fixture.local",
uid: typeof process.getuid === "function" ? process.getuid() : undefined,
listen: host,
},
null,
2,
)}\n`,
);
workerProcess = spawn(
process.execPath,
["--import", "tsx", "../server/src/server/daemon-worker.ts"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable";
},
120000,
"daemon did not enter stale_pid + reachable state in time",
);
const statusBeforeStop = await readDaemonStatus(paseoHome);
assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid");
assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running");
console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`);
console.log(
"Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running",
);
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
const stopJson = JSON.parse(stopResult.stdout) as {
action?: unknown;
pid?: unknown;
message?: unknown;
};
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
assert.strictEqual(
stopJson.pid,
String(stalePid),
"stop should report the stale pid it recovered from",
);
assert.strictEqual(
stopJson.message,
"Daemon stopped gracefully",
"stop should route through lifecycle shutdown",
);
await waitFor(
() => !isProcessRunning(workerProcess?.pid ?? -1),
15000,
"worker remained running after stop",
);
assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop");
console.log("✓ stop recovered stale supervisor pid state\n");
} finally {
if (workerProcess?.pid && isProcessRunning(workerProcess.pid)) {
workerProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(workerProcess!.pid ?? -1),
5000,
"worker cleanup timed out",
).catch(() => {
workerProcess?.kill("SIGKILL");
});
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Stale reachable stop regression test passed ===");
@@ -0,0 +1,192 @@
#!/usr/bin/env npx tsx
/**
* Regression: a supervised daemon worker must exit when its supervisor IPC
* channel closes, instead of becoming an orphaned daemon.
*/
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false;
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null;
}
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null;
}
for (const line of result.stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [pidToken, ppidToken] = trimmed.split(/\s+/);
const pid = Number.parseInt(pidToken ?? "", 10);
const ppid = Number.parseInt(ppidToken ?? "", 10);
if (ppid === supervisorPid && pid > 0) {
return pid;
}
}
return null;
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface DaemonStatus {
localDaemon: string | null;
pid: number | null;
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { localDaemon: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
return {
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
pid:
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null,
};
} catch {
return { localDaemon: null, pid: null };
}
}
console.log("=== Daemon Worker Supervisor Disconnect Regression ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-worker-supervisor-disconnect-"));
const cliRoot = join(import.meta.dirname, "..");
let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log("Test 1: start supervised daemon with isolated PASEO_HOME");
supervisorProcess = spawn(
process.execPath,
["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
supervisorProcess.stderr?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return (
status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
);
},
120000,
"daemon did not become running in time",
);
const statusBeforeKill = await readDaemonStatus(paseoHome);
const supervisorPid = statusBeforeKill.pid;
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
const workerPid = readWorkerPid(supervisorPid);
assert(workerPid !== null, "supervisor should have a worker process");
assert(isProcessRunning(workerPid), "worker process should be running");
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPid}\n`);
console.log("Test 2: killing supervisor should make worker exit via IPC disconnect");
supervisorProcess.kill("SIGKILL");
await waitFor(
() => !isProcessRunning(supervisorPid),
15000,
"supervisor remained running after SIGKILL",
);
await waitFor(
() => !isProcessRunning(workerPid),
15000,
"worker remained running after supervisor IPC disconnect",
);
console.log("✓ worker exited after supervisor disconnect\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill("SIGKILL");
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
if (recentSupervisorLogs.trim().length === 0) {
console.log("(no supervisor logs captured)");
}
console.log("=== Worker supervisor disconnect regression test passed ===");
+111
View File
@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
} from "@getpaseo/protocol/binary-frames/index";
@@ -103,6 +104,12 @@ function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string
return data;
}
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
throw new Error("Expected binary frame");
}
function parseSentFrame(
data: string | Uint8Array | ArrayBuffer | undefined,
): Record<string, unknown> {
@@ -567,6 +574,110 @@ test("readFile resolves from binary file frames when the daemon supports them",
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
chunkSize: 5,
});
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
},
});
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
{
opcode: FileTransferOpcode.FileBegin,
requestId: "req-upload",
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
payload: new Uint8Array(),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode(" worl"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("d"),
},
{
opcode: FileTransferOpcode.FileEnd,
requestId: "req-upload",
payload: new Uint8Array(),
},
]);
mock.triggerMessage(
wrapSessionMessage({
type: "file.upload.response",
payload: {
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
},
}),
);
await expect(responsePromise).resolves.toEqual({
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
});
});
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+68
View File
@@ -22,6 +22,7 @@ import type {
CreateAgentRequestMessage,
CreatePaseoWorktreeRequest,
FileDownloadTokenResponse,
FileUploadResponse,
FileExplorerResponse,
FetchAgentTimelineResponseMessage,
GitSetupOptions,
@@ -90,6 +91,7 @@ import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import {
asUint8Array,
decodeFileTransferFrame,
encodeFileTransferFrame,
decodeTerminalStreamFrame,
FileTransferOpcode,
TerminalStreamOpcode,
@@ -318,6 +320,15 @@ export interface FileReadResult {
kind: LegacyFileExplorerFilePayload["kind"];
modifiedAt: string;
}
export interface FileUploadInput {
fileName: string;
mimeType: string;
bytes: Uint8Array | ArrayBuffer;
modifiedAt?: string;
requestId?: string;
chunkSize?: number;
}
export type FileUploadResult = FileUploadResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
@@ -3316,6 +3327,63 @@ export class DaemonClient {
}
}
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
const bytes = asUint8Array(input.bytes);
if (!bytes) {
throw new Error("File bytes are required.");
}
const resolvedRequestId = this.createRequestId(input.requestId);
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
const responsePromise = this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message: {
type: "file.upload.request",
fileName: input.fileName,
mimeType: input.mimeType,
size: bytes.byteLength,
modifiedAt,
requestId: resolvedRequestId,
},
responseType: "file.upload.response",
timeout: 60000,
options: { skipQueue: true },
});
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: resolvedRequestId,
metadata: {
mime: input.mimeType,
size: bytes.byteLength,
encoding: "binary",
modifiedAt,
fileName: input.fileName,
},
}),
);
const chunkSize = input.chunkSize ?? 1024 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: resolvedRequestId,
payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
}),
);
}
this.sendBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: resolvedRequestId,
}),
);
return responsePromise;
}
async requestDownloadToken(
cwd: string,
path: string,
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import net from "node:net";
import path from "node:path";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const desktopDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(desktopDir, "../..");
const appDir = path.resolve(desktopDir, "../app");
const require = createRequire(import.meta.url);
const electron = require("electron");
const expoPort = Number(process.env.EXPO_PORT);
if (!Number.isInteger(expoPort) || expoPort <= 0) {
console.error("[dev] EXPO_PORT must be set before running desktop dev");
process.exit(1);
}
const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`;
const daemonEndpoint = process.env.PASEO_DAEMON_ENDPOINT || process.env.PASEO_LISTEN || "";
const colorEnv = {
FORCE_COLOR: process.env.FORCE_COLOR || "1",
npm_config_color: process.env.npm_config_color || "always",
};
const children = new Map();
let stopping = false;
let exitCode = 0;
function prefixStream(name, stream, target) {
let buffered = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
buffered += chunk;
const lines = buffered.split(/\r?\n/);
buffered = lines.pop() ?? "";
for (const line of lines) {
target.write(line ? `[${name}] ${line}\n` : `[${name}]\n`);
}
});
stream.on("end", () => {
if (buffered) {
target.write(`[${name}] ${buffered}\n`);
}
});
}
function spawnChild(name, command, args, options = {}) {
const child = spawn(command, args, {
cwd: rootDir,
env: {
...process.env,
...colorEnv,
},
stdio: ["ignore", "pipe", "pipe"],
...options,
});
children.set(name, child);
prefixStream(name, child.stdout, process.stdout);
prefixStream(name, child.stderr, process.stderr);
child.on("error", (error) => {
console.error(`[${name}] failed to start: ${error.message}`);
exitCode = 1;
stopAll("SIGTERM");
});
child.on("exit", (code, signal) => {
children.delete(name);
if (!stopping) {
if (code !== 0) {
exitCode = code ?? 1;
console.error(`[${name}] exited with ${signal ?? code}`);
}
stopAll("SIGTERM");
}
});
return child;
}
function killChild(child, signal) {
if (!child.pid || child.killed) {
return;
}
try {
if (child.detached) {
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch {
// The child may have exited between the liveness check and the signal.
}
}
function stopAll(signal) {
if (stopping) {
return;
}
stopping = true;
for (const child of children.values()) {
killChild(child, signal);
}
const forceKill = setTimeout(() => {
for (const child of children.values()) {
killChild(child, "SIGKILL");
}
}, 2500);
forceKill.unref();
const finish = setInterval(() => {
if (children.size === 0) {
clearInterval(finish);
process.exit(exitCode);
}
}, 50);
}
async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await canConnect(port, host)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${host}:${port}`);
}
function canConnect(port, host) {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("timeout", () => {
socket.destroy();
resolve(false);
});
socket.once("error", () => resolve(false));
});
}
process.on("SIGINT", () => stopAll("SIGTERM"));
process.on("SIGTERM", () => stopAll("SIGTERM"));
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir,
detached: true,
env: {
...process.env,
...colorEnv,
BROWSER: "none",
APP_VARIANT: "development",
PASEO_WEB_PLATFORM: "electron",
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
},
});
try {
await waitForPort(expoPort);
} catch (error) {
console.error(`[dev] ${error.message}`);
exitCode = 1;
stopAll("SIGTERM");
}
if (!stopping) {
spawnChild("electron", electron, [desktopDir], {
detached: true,
env: {
...process.env,
...colorEnv,
EXPO_DEV_URL: expoDevUrl,
},
});
}
+22 -69
View File
@@ -3,88 +3,41 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
# Build the Electron main process
npm run build:main
source "$ROOT_DIR/scripts/dev-home.sh"
# Prefer Metro's stable default port so dev browser storage keeps the same
# localhost origin across restarts. Fall back only when earlier ports are busy.
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085)
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
configure_dev_paseo_home
DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}"
mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR"
if [ -z "${EXPO_PORT:-}" ]; then
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8082 8083 8084 8085 8086 8087 8088 8089)
fi
export EXPO_PORT
export EXPO_DEV_URL="http://localhost:${EXPO_PORT}"
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
# Allow any origin in dev so Electron on random localhost ports can reach
# the daemon websocket. Safe here because this script is development-only
# and the daemon still binds to localhost.
export PASEO_CORS_ORIGINS="*"
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
# works while the installed app is open. Without this the dev build (a) loses the
# Electron single-instance lock to the installed app and quits, and (b) ends up
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
# dev at real data), we DON'T touch your config.json — only the managed home gets
# its daemon config seeded below, so we never rewrite a production ~/.paseo config.
# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate
# single-instance lock, so dev and the installed app coexist.
# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767.
DEV_STATE_DIR="$DESKTOP_DIR/.dev"
if [ -n "${PASEO_HOME:-}" ]; then
PASEO_HOME_MANAGED=0
else
PASEO_HOME="$DEV_STATE_DIR/paseo-home"
PASEO_HOME_MANAGED=1
fi
export PASEO_HOME
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}"
mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR"
DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}"
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}"
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
# daemon is already running by reading `daemon.listen` from this config.json
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
# this it reads the default 6767, finds a production daemon there, and connects
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
# (that could clobber a production config.json with the dev port + wildcard CORS).
if [ "$PASEO_HOME_MANAGED" = "1" ]; then
node -e '
const fs = require("fs");
const [path, port] = [process.argv[1], process.argv[2]];
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
cfg.version = cfg.version || 1;
cfg.daemon = cfg.daemon || {};
cfg.daemon.listen = `127.0.0.1:${port}`;
cfg.daemon.cors = cfg.daemon.cors || {};
cfg.daemon.cors.allowedOrigins = ["*"];
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT"
else
echo " (custom PASEO_HOME — leaving its config.json untouched)"
fi
npm run build:main
echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev"
echo "══════════════════════════════════════════════════════"
echo " Metro: http://localhost:${EXPO_PORT}"
echo " Metro: ${EXPO_DEV_URL}"
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
echo " Daemon: ${PASEO_LISTEN} (isolated)"
echo " PASEO_HOME: ${PASEO_HOME}"
echo " Daemon: ${PASEO_LISTEN}"
echo " Home: ${PASEO_HOME}"
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
echo "══════════════════════════════════════════════════════"
# Launch Metro + Electron together, kill both on exit
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
--kill-others \
--names "metro,electron" \
--prefix-colors "magenta,cyan" \
"cd '$APP_DIR' && PASEO_WEB_PLATFORM=electron npx expo start --port $EXPO_PORT" \
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
exec node "$SCRIPT_DIR/dev-runner.mjs"
@@ -1,10 +1,12 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
settings: {
releaseChannel: "stable",
daemon: {
@@ -21,7 +23,7 @@ vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: false,
isPackaged: true,
},
ipcMain: { handle: vi.fn() },
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
@@ -32,7 +34,7 @@ vi.mock("electron-log/main", () => ({
}));
vi.mock("@getpaseo/server", () => ({
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
resolvePaseoHome: vi.fn(() => mocks.paseoHome),
spawnProcess: mocks.spawnProcess,
}));
@@ -72,8 +74,6 @@ function desktopSettingsWithManagement(enabled: boolean) {
}
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
pid: number;
spawnfile: string;
spawnargs: string[];
@@ -82,8 +82,6 @@ type MockChildProcess = EventEmitter & {
function createMockChildProcess(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 1234;
child.spawnfile = "node";
child.spawnargs = ["node", "daemon.js"];
@@ -91,10 +89,8 @@ function createMockChildProcess(): MockChildProcess {
return child;
}
function scheduleFailedStartupOutput(child: MockChildProcess): void {
function scheduleFailedStartup(child: MockChildProcess): void {
setImmediate(() => {
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
child.emit("exit", 1, null);
});
}
@@ -105,6 +101,11 @@ describe("daemon-manager commands", () => {
mocks.runExternalCliJsonCommand.mockReset();
mocks.runExternalCliTextCommand.mockReset();
mocks.spawnProcess.mockReset();
rmSync(mocks.paseoHome, { recursive: true, force: true });
});
afterEach(() => {
rmSync(mocks.paseoHome, { recursive: true, force: true });
});
it("refuses start and restart while built-in daemon management is disabled", async () => {
@@ -136,7 +137,7 @@ describe("daemon-manager commands", () => {
listen: null,
hostname: null,
pid: null,
home: "/tmp/paseo-home",
home: mocks.paseoHome,
version: null,
desktopManaged: false,
error: null,
@@ -167,7 +168,7 @@ describe("daemon-manager commands", () => {
listen: null,
hostname: null,
pid: null,
home: "/tmp/paseo-home",
home: mocks.paseoHome,
version: null,
desktopManaged: false,
error: null,
@@ -195,7 +196,50 @@ describe("daemon-manager commands", () => {
]);
});
it("uses a reachable daemon when the PID file is stale", async () => {
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
serverId: "",
status: "stopped",
listen: null,
hostname: null,
pid: null,
home: mocks.paseoHome,
version: null,
desktopManaged: false,
error: null,
});
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [
"daemon",
"stop",
"--json",
"--timeout",
"5",
"--force",
"--kill-timeout",
"5",
]);
});
it("uses a stale reachable desktop daemon when the version matches", async () => {
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
@@ -203,7 +247,7 @@ describe("daemon-manager commands", () => {
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
daemonVersion: "1.2.3",
desktopManaged: true,
});
const handlers = createDaemonCommandHandlers();
@@ -214,16 +258,86 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: null,
home: "/tmp/paseo-home",
version: "1.2.2",
desktopManaged: false,
home: mocks.paseoHome,
version: "1.2.3",
desktopManaged: true,
error: null,
});
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("bounds captured daemon startup output", async () => {
it("restarts a stale reachable desktop daemon when the version differs", async () => {
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 7675,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
})
.mockResolvedValueOnce({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
});
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
serverId: "server-2",
status: "running",
listen: "127.0.0.1:6767",
hostname: "dev-host",
pid: 8888,
home: mocks.paseoHome,
version: "1.2.3",
desktopManaged: true,
error: null,
});
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [
"daemon",
"stop",
"--json",
"--timeout",
"5",
"--force",
"--kill-timeout",
"5",
]);
expect(mocks.spawnProcess).toHaveBeenCalled();
});
it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => {
mkdirSync(mocks.paseoHome, { recursive: true });
writeFileSync(
`${mocks.paseoHome}/daemon.log`,
["old log line", "recent daemon failure"].join("\n"),
);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
connectedDaemon: "unreachable",
@@ -231,7 +345,7 @@ describe("daemon-manager commands", () => {
});
mocks.spawnProcess.mockImplementation(() => {
const child = createMockChildProcess();
scheduleFailedStartupOutput(child);
scheduleFailedStartup(child);
return child;
});
const handlers = createDaemonCommandHandlers();
@@ -246,9 +360,15 @@ describe("daemon-manager commands", () => {
expect(thrown).toBeInstanceOf(Error);
const message = thrown?.message ?? "";
expect(message).toContain("Daemon failed to start: exit code 1");
expect(message).toContain("output truncated to the last 65536 chars");
expect(message).toContain("stdout-tail");
expect(message).toContain("stderr-tail");
expect(message.length).toBeLessThan(150_000);
expect(message).toContain(`Recent logs (${mocks.paseoHome}/daemon.log):`);
expect(message).toContain("recent daemon failure");
expect(mocks.spawnProcess).toHaveBeenCalledWith(
"node",
[],
expect.objectContaining({
detached: true,
stdio: ["ignore", "ignore", "ignore"],
}),
);
});
});
+10 -54
View File
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150;
const DETACHED_STARTUP_GRACE_MS = 1200;
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
qr: string | null;
}
interface StartupOutputCapture {
text: string;
truncated: boolean;
}
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
@@ -151,30 +145,6 @@ function tailFile(filePath: string, lines = 50): string {
}
}
function createStartupOutputCapture(): StartupOutputCapture {
return { text: "", truncated: false };
}
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
const nextText = capture.text + chunk.toString();
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
return { text: nextText, truncated: capture.truncated };
}
return {
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
truncated: true,
};
}
function formatStartupOutput(capture: StartupOutputCapture): string {
if (!capture.truncated) {
return capture.text;
}
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
}
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
@@ -231,6 +201,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const desktopManaged = payload.desktopManaged === true;
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
@@ -248,7 +219,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
home,
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
desktopManaged,
error: null,
};
} catch (error) {
@@ -287,19 +258,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
}
}
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: StartupOutputCapture,
stderr: StartupOutputCapture,
): Error {
function buildStartupFailureError(result: {
code: number | null;
signal: string | null;
error?: Error;
}): Error {
const reason = result.error
? result.error.message
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
const parts = [`Daemon failed to start: ${reason}`];
const formattedStderr = formatStartupOutput(stderr).trim();
const formattedStdout = formatStartupOutput(stdout).trim();
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
const logs = tailFile(logFilePath(), 15);
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
return new Error(parts.join("\n\n"));
@@ -377,16 +344,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
envMode: "internal",
env: invocation.env,
envOverlay: { PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = createStartupOutputCapture();
let stderr = createStartupOutputCapture();
child.stdout!.on("data", (data: Buffer) => {
stdout = appendStartupOutput(stdout, data);
});
child.stderr!.on("data", (data: Buffer) => {
stderr = appendStartupOutput(stderr, data);
stdio: ["ignore", "ignore", "ignore"],
});
logDesktopDaemonLifecycle("detached spawn returned", {
@@ -424,8 +382,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
logDesktopDaemonLifecycle("detached startup grace period completed", {
childPid: child.pid ?? null,
exitedEarly: result.exitedEarly,
stdout: formatStartupOutput(stdout).slice(0, 2000),
stderr: formatStartupOutput(stderr).slice(0, 2000),
...(result.exitedEarly
? {
exitCode: result.code,
@@ -436,7 +392,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
});
if (result.exitedEarly) {
throw buildStartupFailureError(result, stdout, stderr);
throw buildStartupFailureError(result);
}
return pollForRunningDaemon();
@@ -444,7 +400,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running" || !status.pid) return status;
if (status.status !== "running") return status;
await stopDesktopDaemonViaCli();
return await resolveDesktopDaemonStatus();
@@ -1,8 +1,34 @@
import { webContents as allWebContents, type WebContents } from "electron";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,
handleBrowserWindowOpenRequest,
isAllowedBrowserWebviewUrl,
} from "./window-open.js";
export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest };
const browserIdsByWebContentsId = new Map<number, string>();
let workspaceActiveBrowserId: string | null = null;
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
return null;
}
const browserId = partition.slice(prefix.length).trim();
return browserId.length > 0 ? browserId : null;
}
export function readBrowserIdFromWebviewAttach(input: {
src?: string;
partition?: string;
}): string | null {
if (!isAllowedBrowserWebviewUrl(input.src)) {
return null;
}
return getBrowserIdFromWebviewPartition(input.partition);
}
export function listRegisteredPaseoBrowserIds(): string[] {
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
}
@@ -45,3 +71,24 @@ export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null
}
return getPaseoBrowserWebContents(workspaceActiveBrowserId);
}
function preventUnsafeBrowserWebviewNavigation(
event: { preventDefault: () => void },
url: string | undefined,
): void {
if (!isAllowedBrowserWebviewUrl(url)) {
event.preventDefault();
}
}
export function registerBrowserWebviewNavigationGuards(contents: WebContents): void {
contents.on("will-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-frame-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-redirect", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { handleBrowserWindowOpenRequest } from ".";
describe("browser webview window-open requests", () => {
it("denies Electron window creation and requests a Paseo browser tab", () => {
const requestNewTab = vi.fn();
const result = handleBrowserWindowOpenRequest({
url: "https://example.com/target",
sourceBrowserId: "browser-1",
requestNewTab,
});
expect(result).toEqual({ action: "deny" });
expect(requestNewTab).toHaveBeenCalledWith({
sourceBrowserId: "browser-1",
url: "https://example.com/target",
});
});
it("denies unsupported window-open requests before asking for a Paseo browser tab", () => {
const requestNewTab = vi.fn();
const result = handleBrowserWindowOpenRequest({
url: "file:///etc/passwd",
sourceBrowserId: "browser-1",
requestNewTab,
});
expect(result).toEqual({ action: "deny" });
expect(requestNewTab).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
export const BROWSER_NEW_TAB_REQUEST_EVENT = "paseo:event:browser-new-tab-request";
export interface BrowserNewTabRequestPayload {
sourceBrowserId: string;
url: string;
}
export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
return true;
}
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
export function handleBrowserWindowOpenRequest(input: {
url: string;
sourceBrowserId: string | null;
requestNewTab: (payload: BrowserNewTabRequestPayload) => void;
}): { action: "deny" } {
if (!isAllowedBrowserWebviewUrl(input.url) || !input.sourceBrowserId) {
return { action: "deny" };
}
input.requestNewTab({
sourceBrowserId: input.sourceBrowserId,
url: input.url,
});
return { action: "deny" };
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { app, Menu, BrowserWindow, ipcMain } from "electron";
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews.js";
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
interface ShowContextMenuInput {
kind?: "terminal";
+16 -53
View File
@@ -46,12 +46,16 @@ import { registerOpenerHandlers } from "./features/opener.js";
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,
getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContents,
handleBrowserWindowOpenRequest,
listRegisteredPaseoBrowserIds,
readBrowserIdFromWebviewAttach,
registerBrowserWebviewNavigationGuards,
registerPaseoBrowserWebContents,
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews.js";
} from "./features/browser-webviews/index.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
@@ -73,28 +77,6 @@ const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
return true;
}
try {
const parsed = new URL(value);
return (
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
);
} catch {
return false;
}
}
function preventUnsafeBrowserWebviewNavigation(
event: Electron.Event,
url: string | undefined,
): void {
if (!isAllowedBrowserWebviewUrl(url)) {
event.preventDefault();
}
}
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
@@ -127,15 +109,6 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName(APP_NAME);
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
return null;
}
const browserId = partition.slice(prefix.length).trim();
return browserId.length > 0 ? browserId : null;
}
const pendingBrowserWebviewIds: string[] = [];
function isBrowserRefreshInput(input: Electron.Input): boolean {
@@ -470,11 +443,7 @@ async function createWindow(
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
if (!isAllowedBrowserWebviewUrl(params.src)) {
event.preventDefault();
return;
}
const browserId = getBrowserIdFromWebviewPartition(params.partition);
const browserId = readBrowserIdFromWebviewAttach(params);
if (!browserId) {
event.preventDefault();
return;
@@ -534,25 +503,19 @@ async function createWindow(
});
}
});
contents.setWindowOpenHandler(({ url }) => {
if (!isAllowedBrowserWebviewUrl(url)) {
return { action: "deny" };
}
contents.loadURL(url).catch(() => undefined);
return { action: "deny" };
});
contents.setWindowOpenHandler(({ url }) =>
handleBrowserWindowOpenRequest({
url,
sourceBrowserId: getPaseoBrowserIdForWebContents(contents),
requestNewTab: (payload) => {
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload);
},
}),
);
contents.on("context-menu", (_contextMenuEvent, params) => {
showBrowserWebviewContextMenu(mainWindow, contents, params);
});
contents.on("will-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-frame-navigate", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
contents.on("will-redirect", (event) => {
preventUnsafeBrowserWebviewNavigation(event, event.url);
});
registerBrowserWebviewNavigationGuards(contents);
});
mainWindow.once("ready-to-show", () => {
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
decodeBinaryFrame,
encodeFileTransferFrame,
encodeTerminalStreamFrame,
FileTransferOpcode,
TerminalStreamOpcode,
} from "./index.js";
describe("binary frame demux", () => {
it("routes terminal frames by opcode", () => {
expect(
decodeBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot: 7,
payload: "ls",
}),
),
).toEqual({
kind: "terminal",
frame: {
opcode: TerminalStreamOpcode.Input,
slot: 7,
payload: new TextEncoder().encode("ls"),
},
});
});
it("routes file-transfer frames by opcode", () => {
expect(
decodeBinaryFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
}),
),
).toEqual({
kind: "file_transfer",
frame: {
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
},
});
});
it("rejects unknown binary opcodes", () => {
expect(decodeBinaryFrame(new Uint8Array([0xff, 0]))).toBeNull();
});
});
@@ -0,0 +1,35 @@
import {
decodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "./file-transfer.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "./terminal.js";
export type BinaryFrame =
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
case TerminalStreamOpcode.Output:
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null;
}
case FileTransferOpcode.FileBegin:
case FileTransferOpcode.FileChunk:
case FileTransferOpcode.FileEnd: {
const frame = decodeFileTransferFrame(bytes);
return frame ? { kind: "file_transfer", frame } : null;
}
default:
return null;
}
}
@@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({
size: z.number().int().nonnegative(),
encoding: z.enum(["utf-8", "binary"]),
modifiedAt: z.string(),
fileName: z.string().optional(),
});
export interface FileBegin {
@@ -1,2 +1,3 @@
export * from "./demux.js";
export * from "./file-transfer.js";
export * from "./terminal.js";
+32
View File
@@ -839,11 +839,21 @@ export const ReviewAttachmentSchema = z.object({
comments: z.array(ReviewAttachmentCommentSchema),
});
export const UploadedFileAttachmentSchema = z.object({
type: z.literal("uploaded_file"),
id: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number().int().nonnegative(),
path: z.string(),
});
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
GitHubPrAttachmentSchema,
GitHubIssueAttachmentSchema,
TextAttachmentSchema,
ReviewAttachmentSchema,
UploadedFileAttachmentSchema,
]);
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
@@ -1680,6 +1690,15 @@ export const FileDownloadTokenRequestSchema = z.object({
requestId: z.string(),
});
export const FileUploadRequestSchema = z.object({
type: z.literal("file.upload.request"),
fileName: z.string().min(1),
mimeType: z.string().min(1),
size: z.number().int().nonnegative(),
modifiedAt: z.string(),
requestId: z.string(),
});
export const ClearAgentAttentionMessageSchema = z.object({
type: z.literal("clear_agent_attention"),
agentId: z.union([z.string(), z.array(z.string())]),
@@ -1911,6 +1930,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
FileUploadRequestSchema,
ClearAgentAttentionMessageSchema,
ClientHeartbeatMessageSchema,
PingMessageSchema,
@@ -3436,6 +3456,15 @@ export const FileDownloadTokenResponseSchema = z.object({
}),
});
export const FileUploadResponseSchema = z.object({
type: z.literal("file.upload.response"),
payload: z.object({
requestId: z.string(),
file: UploadedFileAttachmentSchema.nullable(),
error: z.string().nullable(),
}),
});
export const ListProviderModelsResponseMessageSchema = z.object({
type: z.literal("list_provider_models_response"),
payload: z.object({
@@ -3756,6 +3785,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileExplorerResponseSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
FileUploadResponseSchema,
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
@@ -4056,6 +4086,8 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type FileUploadRequest = z.infer<typeof FileUploadRequestSchema>;
export type FileUploadResponse = z.infer<typeof FileUploadResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
+14
View File
@@ -24,6 +24,10 @@ type WorkerLifecycleMessage =
reason?: string;
};
interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface SupervisorOptions {
name: string;
startupMessage: string;
@@ -183,6 +187,15 @@ export function runSupervisor(options: SupervisorOptions): void {
});
}
const currentChild = child;
const heartbeat = setInterval(() => {
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
if (currentChild.connected) {
currentChild.send?.(message, () => undefined);
}
}, 1000);
heartbeat.unref();
child.stdout?.on("data", (chunk: Buffer) => {
process.stdout.write(chunk);
writeDurableChunk(chunk);
@@ -224,6 +237,7 @@ export function runSupervisor(options: SupervisorOptions): void {
});
child.on("close", (code, signal) => {
clearInterval(heartbeat);
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
@@ -28,6 +28,7 @@ import type {
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
interface Deferred<T> {
promise: Promise<T>;
@@ -522,7 +523,20 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
class CaptureClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | null = null;
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = launchContext ?? null;
return super.resumeSession(handle, config, launchContext);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
@@ -542,7 +556,7 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(snapshot.config).not.toHaveProperty("daemonAppendSystemPrompt");
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
});
@@ -551,7 +565,20 @@ test("daemon append system prompt is injected into Pi configs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
class CaptureClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | null = null;
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = launchContext ?? null;
return super.resumeSession(handle, config, launchContext);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
pi: client as unknown as AgentClient,
@@ -948,28 +975,38 @@ test("createAgent passes persistSession to provider create options", async () =>
rmSync(workdir, { recursive: true, force: true });
});
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
test("createAgent passes internal paseo tooling only through runtime launch context", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
lastLaunchContext: AgentLaunchContext | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastConfig = config;
this.lastLaunchContext = launchContext ?? null;
return new TestAgentSession(config);
}
}
const client = new CaptureClient();
const paseoToolingRuntime = new PaseoToolingRuntime();
paseoToolingRuntime.setEndpoints({
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
httpBaseUrl: "http://127.0.0.1:6767",
});
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
paseoToolingRuntime,
idFactory: () => "00000000-0000-4000-8000-000000000103",
});
@@ -985,16 +1022,134 @@ test("createAgent injects paseo MCP server when manager has an MCP base URL", as
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
},
custom: {
type: "stdio",
command: "custom-mcp",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
expect(client.lastConfig?.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
`http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
);
expect(client.lastLaunchContext?.paseoTooling?.httpBaseUrl).toBe("http://127.0.0.1:6767");
const stored = await storage.get(snapshot.id);
expect(stored?.config?.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
});
test("resumeAgentFromPersistence strips stored internal paseo MCP and passes current runtime tooling", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | null = null;
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = launchContext ?? null;
return super.resumeSession(handle, config, launchContext);
}
}
const client = new CaptureClient();
const paseoToolingRuntime = new PaseoToolingRuntime();
paseoToolingRuntime.setEndpoints({
mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents",
httpBaseUrl: "http://127.0.0.1:6768",
});
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
paseoToolingRuntime,
idFactory: () => "00000000-0000-4000-8000-000000000105",
});
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "session-123",
metadata: {
cwd: workdir,
},
};
const snapshot = await manager.resumeAgentFromPersistence(handle, {
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
},
custom: {
type: "stdio",
command: "custom-mcp",
},
},
});
expect(client.resumeOverrides[0]?.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
`http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`,
);
expect(snapshot.config.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
});
test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime injection is disabled", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
});
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "session-123",
metadata: {
cwd: workdir,
},
};
const snapshot = await manager.resumeAgentFromPersistence(handle, {
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
},
},
});
expect(client.resumeOverrides[0]?.mcpServers).toBeUndefined();
expect(snapshot.config.mcpServers).toBeUndefined();
});
test("createAgent preserves a user-provided paseo MCP config", async () => {
@@ -1018,7 +1173,6 @@ test("createAgent preserves a user-provided paseo MCP config", async () => {
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
@@ -57,6 +57,8 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer } from "./runtime-mcp-config.js";
import type { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -74,6 +76,11 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
type TimeoutResult = "completed" | "timed_out";
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
}
interface TimeoutOptions {
operation: Promise<void>;
timeoutMs: number;
@@ -105,7 +112,7 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
config.systemPrompt = record.config.systemPrompt;
}
if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers;
return config;
return stripInternalPaseoMcpServer(config);
}
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
@@ -183,7 +190,7 @@ export interface AgentManagerOptions {
onAgentAttention?: AgentAttentionCallback;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
paseoToolingRuntime?: PaseoToolingRuntime;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
@@ -426,7 +433,7 @@ export class AgentManager {
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly paseoToolingRuntime: PaseoToolingRuntime | null;
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private onAgentArchived?: AgentArchivedCallback;
@@ -438,7 +445,7 @@ export class AgentManager {
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.paseoToolingRuntime = options?.paseoToolingRuntime ?? null;
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = {
@@ -494,10 +501,6 @@ export class AgentManager {
this.onAgentArchived = callback;
}
setMcpBaseUrl(url: string | null): void {
this.mcpBaseUrl = url;
}
setAppendSystemPrompt(prompt: string | null | undefined): void {
this.appendSystemPrompt = prompt ?? "";
}
@@ -811,30 +814,15 @@ export class AgentManager {
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const injectedConfig =
this.mcpBaseUrl == null
? config
: {
...config,
mcpServers: {
paseo: {
type: "http" as const,
url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`,
},
...config.mcpServers,
},
};
this.requireEnabledProvider(injectedConfig.provider);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(injectedConfig),
);
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
this.requireEnabledProvider(storedConfig.provider);
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
provider: storedConfig.provider,
});
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(normalizedConfig, launchContext, createOptions);
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
const session = await client.createSession(launchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options?.labels,
workspaceId: options?.workspaceId,
initialTitle: options?.initialTitle,
@@ -872,26 +860,10 @@ export class AgentManager {
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(mergedConfig),
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
);
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
let hasResumeOverrides = overrides !== undefined;
if (normalizedConfig.model !== mergedConfig.model) {
resumeOverrides.model = normalizedConfig.model;
hasResumeOverrides = true;
}
if (normalizedConfig.modeId !== mergedConfig.modeId) {
resumeOverrides.modeId = normalizedConfig.modeId;
hasResumeOverrides = true;
}
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
hasResumeOverrides = true;
}
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
@@ -901,12 +873,8 @@ export class AgentManager {
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.resumeSession(
handle,
hasResumeOverrides ? resumeOverrides : undefined,
launchContext,
);
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
const session = await client.resumeSession(handle, launchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
// Hot-reload an active agent session with config overrides. By default the
@@ -938,14 +906,12 @@ export class AgentManager {
...overrides,
provider,
} as AgentSessionConfig;
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(refreshConfig),
);
const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId);
const launchContext = this.buildLaunchContext(agentId);
const session = handle
? await client.resumeSession(handle, normalizedConfig, launchContext)
: await client.createSession(normalizedConfig, launchContext);
? await client.resumeSession(handle, launchConfig, launchContext)
: await client.createSession(launchConfig, launchContext);
this.agentStreamCoalescer.flushAndDiscard(agentId);
// Remove the existing agent entry before swapping sessions
@@ -966,7 +932,7 @@ export class AgentManager {
}
// Preserve existing labels and timeline during reload.
return this.registerSession(session, normalizedConfig, agentId, {
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
@@ -3489,6 +3455,15 @@ export class AgentManager {
return normalized;
}
private async prepareSessionConfig(
config: AgentSessionConfig,
_agentId: string,
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
const launchConfig = this.applyDaemonAppendSystemPrompt(storedConfig);
return { storedConfig, launchConfig };
}
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
const next = { ...config };
@@ -3505,6 +3480,7 @@ export class AgentManager {
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
return {
agentId,
paseoTooling: this.paseoToolingRuntime?.createLaunchContext(agentId),
env: {
...env,
PASEO_AGENT_ID: agentId,
@@ -546,9 +546,23 @@ export interface AgentSessionConfig {
internal?: boolean;
}
export interface PaseoToolingProviderSessionRef {
provider: AgentProvider;
sessionId: string;
}
export interface PaseoToolingLaunchContext {
agentId: string;
mcpUrl: string | null;
httpBaseUrl: string | null;
token: string;
bindProviderSession(ref: PaseoToolingProviderSessionRef): () => void;
}
export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>;
paseoTooling?: PaseoToolingLaunchContext;
}
export interface AgentCreateSessionOptions {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import type express from "express";
import type { Logger } from "pino";
import { z } from "zod";
import type { AgentProvider } from "../agent-sdk-types.js";
import type { PaseoToolRegistry } from "./registry.js";
import type { PaseoToolingRuntime } from "./runtime.js";
const ToolExecuteBodySchema = z.object({
provider: z.string().min(1),
sessionId: z.string().min(1),
tool: z.string().min(1),
input: z.unknown().optional(),
});
export interface MountPaseoToolingHttpAdapterOptions {
app: express.Express;
registry: PaseoToolRegistry;
runtime: PaseoToolingRuntime;
logger: Logger;
}
export function mountPaseoToolingHttpAdapter(options: MountPaseoToolingHttpAdapterOptions): void {
const { app, registry, runtime, logger } = options;
const requireAuth: express.RequestHandler = (req, res, next) => {
if (req.header("authorization") === `Bearer ${runtime.token}`) {
next();
return;
}
res.status(401).json({ error: "Unauthorized" });
};
app.get("/api/paseo-tooling/manifest", requireAuth, (_req, res) => {
res.json({ tools: registry.listManifest() });
});
const runExecuteRequest = async (req: express.Request, res: express.Response): Promise<void> => {
const parsed = ToolExecuteBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const callerAgentId = runtime.resolveProviderSession({
provider: parsed.data.provider as AgentProvider,
sessionId: parsed.data.sessionId,
});
if (!callerAgentId) {
res.status(404).json({ error: "Provider session is not bound to a Paseo agent" });
return;
}
try {
const result = await registry.executeTool({
name: parsed.data.tool,
input: parsed.data.input ?? {},
callerAgentId,
});
res.json({ output: formatPaseoToolResult(result) });
} catch (err) {
logger.error(
{
err,
provider: parsed.data.provider,
sessionId: parsed.data.sessionId,
tool: parsed.data.tool,
},
"Paseo tooling HTTP execution failed",
);
res.status(500).json({ error: err instanceof Error ? err.message : "Tool execution failed" });
}
};
app.post("/api/paseo-tooling/execute", requireAuth, (req, res) => {
void runExecuteRequest(req, res);
});
}
function formatPaseoToolResult(result: unknown): unknown {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return result;
}
const record = result as Record<string, unknown>;
if (record.structuredContent !== undefined) {
return record.structuredContent;
}
if (!Array.isArray(record.content)) {
return result;
}
const text = record.content
.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) {
return null;
}
const value = (part as Record<string, unknown>).text;
return typeof value === "string" ? value : null;
})
.filter((part): part is string => part !== null)
.join("\n");
return text || record.content;
}
@@ -0,0 +1,21 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
createPaseoToolRegistry,
type AgentMcpServerOptions,
type PaseoToolingRegistryOptions,
} from "./registry.js";
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
const server = new McpServer({
name: "agent-mcp",
version: "2.0.0",
});
const registry = createPaseoToolRegistry(options);
for (const tool of registry.listTools()) {
server.registerTool(tool.name, tool.config, tool.handler);
}
return server;
}
export type { AgentMcpServerOptions, PaseoToolingRegistryOptions };
@@ -0,0 +1,24 @@
import type { AgentLaunchContext, AgentSessionConfig } from "../agent-sdk-types.js";
const PASEO_MCP_SERVER_NAME = "paseo";
export function withPaseoToolingMcpServer(
config: AgentSessionConfig,
launchContext: AgentLaunchContext | undefined,
): AgentSessionConfig {
const mcpUrl = launchContext?.paseoTooling?.mcpUrl;
if (!mcpUrl || config.mcpServers?.[PASEO_MCP_SERVER_NAME]) {
return config;
}
return {
...config,
mcpServers: {
[PASEO_MCP_SERVER_NAME]: {
type: "http",
url: mcpUrl,
},
...config.mcpServers,
},
};
}
@@ -0,0 +1,54 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createPaseoToolRegistry } from "./registry.js";
describe("Paseo tooling registry", () => {
test("executes tools with runtime caller identity", async () => {
const callerCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-tooling-caller-"));
const requestedCwd = path.join(callerCwd, "child");
const createdTerminals: Array<{ cwd: string; name?: string }> = [];
const terminalManager = {
createTerminal: async (input: { cwd: string; name?: string }) => {
createdTerminals.push(input);
return { id: "terminal-1", name: input.name ?? "Terminal", cwd: input.cwd };
},
};
const agentManager = {
getAgent: (agentId: string) =>
agentId === "agent-1" ? { cwd: callerCwd, config: {} } : undefined,
};
try {
const registry = createPaseoToolRegistry({
agentManager: agentManager as never,
agentStorage: {} as never,
terminalManager: terminalManager as never,
providerSnapshotManager: {} as never,
agentScopedTools: true,
logger: createTestLogger(),
});
const result = await registry.executeTool({
name: "create_terminal",
callerAgentId: "agent-1",
input: {
cwd: "child",
name: "Build",
},
});
expect(createdTerminals).toEqual([{ cwd: requestedCwd, name: "Build" }]);
expect(result.structuredContent).toEqual({
id: "terminal-1",
name: "Build",
cwd: requestedCwd,
});
} finally {
rmSync(callerCwd, { recursive: true, force: true });
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { randomBytes } from "node:crypto";
import type {
AgentProvider,
PaseoToolingLaunchContext,
PaseoToolingProviderSessionRef,
} from "../agent-sdk-types.js";
interface PaseoToolingRuntimeEndpoints {
mcpBaseUrl: string | null;
httpBaseUrl: string | null;
}
export class PaseoToolingRuntime {
private mcpBaseUrl: string | null = null;
private httpBaseUrl: string | null = null;
private enabled = true;
private readonly sessionBindings = new Map<string, string>();
readonly token = randomBytes(32).toString("hex");
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
setEndpoints(endpoints: PaseoToolingRuntimeEndpoints): void {
this.mcpBaseUrl = endpoints.mcpBaseUrl;
this.httpBaseUrl = endpoints.httpBaseUrl;
}
createLaunchContext(agentId: string): PaseoToolingLaunchContext | undefined {
if (!this.enabled || (!this.mcpBaseUrl && !this.httpBaseUrl)) {
return undefined;
}
return {
agentId,
mcpUrl: this.mcpBaseUrl ? `${this.mcpBaseUrl}?callerAgentId=${agentId}` : null,
httpBaseUrl: this.httpBaseUrl,
token: this.token,
bindProviderSession: (ref) => this.bindProviderSession({ ...ref, agentId }),
};
}
bindProviderSession(ref: PaseoToolingProviderSessionRef & { agentId: string }): () => void {
const key = providerSessionKey(ref.provider, ref.sessionId);
this.sessionBindings.set(key, ref.agentId);
return () => {
if (this.sessionBindings.get(key) === ref.agentId) {
this.sessionBindings.delete(key);
}
};
}
resolveProviderSession(ref: PaseoToolingProviderSessionRef): string | null {
return this.sessionBindings.get(providerSessionKey(ref.provider, ref.sessionId)) ?? null;
}
}
function providerSessionKey(provider: AgentProvider, sessionId: string): string {
return `${provider}:${sessionId}`;
}
@@ -96,6 +96,26 @@ describe("prompt attachments", () => {
).toBe("<browser-element>button.primary</browser-element>");
});
it("renders uploaded file attachments as local file references", () => {
expect(
renderPromptAttachmentAsText({
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo/uploads/upload_req-upload/notes.txt",
}),
).toBe(
[
"Uploaded file: notes.txt",
"Path: /tmp/paseo/uploads/upload_req-upload/notes.txt",
"MIME: text/plain",
"Size: 11 bytes",
].join("\n"),
);
});
it("returns undefined when firstAgentContext is empty", () => {
expect(buildAgentBranchNameSeed(undefined)).toBeUndefined();
expect(buildAgentBranchNameSeed({})).toBeUndefined();
@@ -54,6 +54,14 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
});
return lines.join("\n");
}
case "uploaded_file": {
return [
`Uploaded file: ${attachment.fileName}`,
`Path: ${attachment.path}`,
`MIME: ${attachment.mimeType}`,
`Size: ${attachment.size} bytes`,
].join("\n");
}
default:
throw new Error("unreachable");
}
@@ -93,6 +93,7 @@ import {
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
@@ -596,29 +597,30 @@ export class ACPAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.assertProvider(config);
const session = new ACPAgentSession(
const launchConfig = withPaseoToolingMcpServer(
{ ...config, provider: this.provider },
{
provider: this.provider,
logger: this.logger,
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
},
launchContext,
);
const session = new ACPAgentSession(launchConfig, {
provider: this.provider,
logger: this.logger,
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
defaultModes: this.defaultModes,
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
beforeModeWriter: this.beforeModeWriter,
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
});
await session.initializeNewSession();
return session;
}
@@ -638,12 +640,15 @@ export class ACPAgentClient implements AgentClient {
throw new Error(`${this.provider} resume requires the original working directory`);
}
const mergedConfig: AgentSessionConfig = {
...storedConfig,
...overrides,
provider: this.provider,
cwd,
};
const mergedConfig = withPaseoToolingMcpServer(
{
...storedConfig,
...overrides,
provider: this.provider,
cwd,
},
launchContext,
);
const session = new ACPAgentSession(mergedConfig, {
provider: this.provider,
logger: this.logger,
@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
import { claudeProjectDirSync } from "./project-dir.js";
import {
@@ -1297,7 +1298,7 @@ export class ClaudeAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(config, launchContext));
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
@@ -1325,7 +1326,7 @@ export class ClaudeAgentClient implements AgentClient {
provider: "claude",
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(mergedConfig, launchContext));
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
@@ -534,6 +534,37 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("initializes Codex app-server without making Paseo the request originator", async () => {
let initializeParams: unknown;
const appServer = createFakeCodexAppServer({
initialize: (params) => {
initializeParams = params;
return {};
},
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.connect();
expect(initializeParams).toEqual({
clientInfo: {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
},
capabilities: { experimentalApi: true },
});
appServer.assertNoErrors();
await session.close();
});
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
@@ -2703,7 +2734,17 @@ describe("Codex persisted sessions", () => {
}),
);
expect(calls).toEqual([
{ method: "initialize", params: expect.any(Object) },
{
method: "initialize",
params: {
clientInfo: {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
},
capabilities: { experimentalApi: true },
},
},
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
]);
});
@@ -85,6 +85,7 @@ import {
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes(
@@ -103,6 +104,14 @@ const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
// Codex treats most app-server client names as the model-request originator.
// This reserved Codex name is non-originating, so requests keep Codex's default
// CLI identity instead of showing up as Paseo in provider usage logs.
const CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO = {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
} as const;
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
const CODEX_TOOL_THREAD_ITEM_TYPES = new Set([
"commandExecution",
@@ -2818,11 +2827,7 @@ function buildCodexAppServerInitializeParams(): {
capabilities: { experimentalApi: true };
} {
return {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
capabilities: {
experimentalApi: true,
},
@@ -5429,7 +5434,10 @@ export class CodexAppServerAgentClient implements AgentClient {
// TODO: Honor persistSession=false if app-server adds support, or route
// utility generations through `codex exec --ephemeral` in a larger change.
}
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const sessionConfig = withPaseoToolingMcpServer(
{ ...config, provider: CODEX_PROVIDER },
launchContext,
);
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const session = new CodexAppServerAgentSession(
@@ -5454,12 +5462,15 @@ export class CodexAppServerAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = {
...storedConfig,
...overrides,
provider: CODEX_PROVIDER,
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
};
const merged = withPaseoToolingMcpServer(
{
...storedConfig,
...overrides,
provider: CODEX_PROVIDER,
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
},
launchContext,
);
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const session = new CodexAppServerAgentSession(
@@ -16,6 +16,7 @@ import {
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
import type {
AgentLaunchContext,
AgentSessionConfig,
AgentStreamEvent,
ToolCallTimelineItem,
@@ -112,6 +113,66 @@ function assistantTurnEvents({
];
}
function manualCompactEvents({
sessionId = "session-1",
summaryText = "## Goal\n- Preserve context while continuing the task.",
}: {
sessionId?: string;
summaryText?: string;
} = {}): OpenCodeEvent[] {
return [
{
type: "message.updated",
properties: {
info: {
id: "msg_compact_user",
sessionID: sessionId,
role: "user",
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "prt_compact",
sessionID: sessionId,
messageID: "msg_compact_user",
type: "compaction",
auto: false,
},
},
},
{
type: "message.updated",
properties: {
info: {
id: "msg_compact_summary",
sessionID: sessionId,
role: "assistant",
providerID: "test-provider",
modelID: "gpt-5.5",
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "prt_compact_summary",
sessionID: sessionId,
messageID: "msg_compact_summary",
type: "text",
text: summaryText,
time: { start: 1, end: 2 },
},
},
},
{ type: "session.compacted", properties: { sessionID: sessionId } },
{ type: "session.idle", properties: { sessionID: sessionId } },
];
}
describe("OpenCodeAgentClient adapter smoke tests", () => {
const logger = createTestLogger();
const buildConfig = (cwd: string): AgentSessionConfig => ({
@@ -168,6 +229,41 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
}, 120_000);
test("manual compact hides the generated summary text", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd,
model: "test-provider/gpt-5.5",
});
const turn = await collectTurnEvents(streamSession(session, "/compact"));
expect(turn.turnCompleted).toBe(true);
expect(turn.assistantMessages).toEqual([]);
expect(turn.allTimelineItems).toEqual([
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
{ type: "compaction", status: "loading", trigger: "manual" },
{ type: "compaction", status: "completed" },
]);
expect(openCodeClient.calls.sessionSummarize).toEqual([
expect.objectContaining({
sessionID: "session-1",
directory: cwd,
providerID: "test-provider",
modelID: "gpt-5.5",
}),
]);
await session.close();
rmSync(cwd, { recursive: true, force: true });
}, 120_000);
test("listModels returns models with required fields", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
@@ -757,6 +853,55 @@ describe("OpenCode adapter context-window normalization", () => {
});
describe("OpenCode adapter startTurn error handling", () => {
test("uses native Paseo tooling plugin instead of registering internal MCP", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const boundSessions: string[] = [];
const unboundSessions: string[] = [];
const launchContext: AgentLaunchContext = {
agentId: "agent-1",
env: { PASEO_AGENT_ID: "agent-1" },
paseoTooling: {
agentId: "agent-1",
mcpUrl: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
httpBaseUrl: "http://127.0.0.1:6767",
token: "tool-token",
bindProviderSession: ({ provider, sessionId }) => {
expect(provider).toBe("opencode");
boundSessions.push(sessionId);
return () => unboundSessions.push(sessionId);
},
},
};
try {
const session = await client.createSession(
{
provider: "opencode",
cwd,
},
launchContext,
);
await collectTurnEvents(streamSession(session, "hello"));
expect(runtime.acquisitions[0]?.env?.PASEO_AGENT_ID).toBe("agent-1");
expect(runtime.acquisitions[0]?.env?.OPENCODE_CONFIG_CONTENT).toContain(
"paseo-tooling-plugin.mjs",
);
expect(openCodeClient.calls.mcpAdd).toEqual([]);
expect(boundSessions).toEqual(["session-1"]);
await session.close();
expect(unboundSessions).toEqual(["session-1"]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("dynamically adds injected MCP servers without config-backed connect", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
@@ -1595,6 +1740,155 @@ describe("OpenCodeAgentClient env", () => {
});
describe("OpenCode persisted sessions", () => {
test("replay hides summaries produced by manual compact", () => {
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
{
info: {
id: "msg_compact_user",
sessionID: "ses_1",
role: "user",
time: { created: 1000 },
agent: "build",
model: { providerID: "test-provider", modelID: "gpt-5.5" },
},
parts: [
{
id: "prt_compact_text",
sessionID: "ses_1",
messageID: "msg_compact_user",
type: "text",
text: "/compact",
},
{
id: "prt_compact",
sessionID: "ses_1",
messageID: "msg_compact_user",
type: "compaction",
auto: false,
},
],
},
{
info: {
id: "msg_compact_summary",
sessionID: "ses_1",
role: "assistant",
time: { created: 1001, completed: 1002 },
providerID: "test-provider",
modelID: "gpt-5.5",
},
parts: [
{
id: "prt_summary",
sessionID: "ses_1",
messageID: "msg_compact_summary",
type: "text",
text: "## Goal\n- Preserve context while continuing the task.",
},
],
},
{
info: {
id: "msg_next_user",
sessionID: "ses_1",
role: "user",
time: { created: 1003 },
agent: "build",
model: { providerID: "test-provider", modelID: "gpt-5.5" },
},
parts: [
{
id: "prt_next_user",
sessionID: "ses_1",
messageID: "msg_next_user",
type: "text",
text: "continue",
},
],
},
]);
expect(timeline).toEqual([
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
{ type: "compaction", status: "completed", trigger: "manual" },
{ type: "user_message", text: "continue", messageId: "msg_next_user" },
]);
});
test("replay suppresses OpenCode compaction summary messages", () => {
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
{
info: {
id: "msg_compaction_user",
sessionID: "ses_1",
role: "user",
time: { created: 1000 },
agent: "build",
model: { providerID: "opencode", modelID: "big-pickle" },
},
parts: [
{
id: "prt_compaction",
sessionID: "ses_1",
messageID: "msg_compaction_user",
type: "compaction",
auto: true,
},
],
},
{
info: {
id: "msg_compaction_summary",
sessionID: "ses_1",
role: "assistant",
time: { created: 1001, completed: 1002 },
parentID: "msg_compaction_user",
providerID: "opencode",
modelID: "big-pickle",
mode: "compaction",
agent: "compaction",
path: { cwd: "/workspace/repo", root: "/workspace/repo" },
summary: true,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
{
id: "prt_summary",
sessionID: "ses_1",
messageID: "msg_compaction_summary",
type: "text",
text: "## Goal\n- Preserve context while continuing the task.",
},
],
},
{
info: {
id: "msg_user_after_compaction",
sessionID: "ses_1",
role: "user",
time: { created: 1003 },
agent: "build",
model: { providerID: "opencode", modelID: "big-pickle" },
},
parts: [
{
id: "prt_user_after_compaction",
sessionID: "ses_1",
messageID: "msg_user_after_compaction",
type: "text",
text: "/create-pr",
},
],
},
]);
expect(timeline).toEqual([
{ type: "compaction", status: "completed", trigger: "auto" },
{ type: "user_message", text: "/create-pr", messageId: "msg_user_after_compaction" },
]);
});
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
@@ -78,6 +78,11 @@ import {
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
import {
bindOpenCodePaseoToolingSession,
ensureOpenCodePaseoToolingRuntime,
withOpenCodePaseoToolingEnv,
} from "./opencode/paseo-tooling.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
@@ -1040,6 +1045,21 @@ function buildOpenCodeReplayPartTimelineEvent(params: {
});
}
function isOpenCodeCompactionSummaryMessage(message: OpenCodeMessage): boolean {
return (
message.role === "assistant" &&
(message.summary === true || message.agent === "compaction" || message.mode === "compaction")
);
}
function findOpenCodeCompactionPart(
message: OpenCodeSessionMessage,
): Extract<OpenCodePart, { type: "compaction" }> | undefined {
return message.parts.find(
(part): part is Extract<OpenCodePart, { type: "compaction" }> => part.type === "compaction",
);
}
async function readOpenCodeSessionMessagesFromSdk(
client: Pick<OpencodeClient, "session">,
session: OpenCodePersistedSession,
@@ -1059,9 +1079,30 @@ async function readOpenCodeSessionMessagesFromSdk(
function buildOpenCodeSessionTimeline(
messages: ReadonlyArray<OpenCodeSessionMessage>,
): AgentTimelineItem[] {
return messages.flatMap((message) =>
buildOpenCodeReplayTimelineEvents(message).map((event) => event.item),
);
const timeline: AgentTimelineItem[] = [];
let hideNextAssistantAfterCompaction = false;
for (const message of messages) {
const compactionPart = findOpenCodeCompactionPart(message);
if (message.info.role === "assistant" && hideNextAssistantAfterCompaction) {
hideNextAssistantAfterCompaction = false;
continue;
}
if (message.info.role === "user" && !compactionPart) {
hideNextAssistantAfterCompaction = false;
}
timeline.push(...buildOpenCodeReplayTimelineEvents(message).map((event) => event.item));
if (message.info.role === "user" && compactionPart) {
timeline.push(
createCompactionTimelineItem("completed", compactionPart.auto ? "auto" : "manual"),
);
hideNextAssistantAfterCompaction = true;
}
}
return timeline;
}
function filterOpenCodeRevertedMessages(
@@ -1120,6 +1161,9 @@ function buildOpenCodeReplayTimelineEvents(
message: OpenCodeSessionMessage,
): Extract<AgentStreamEvent, { type: "timeline" }>[] {
const { info, parts } = message;
if (isOpenCodeCompactionSummaryMessage(info)) {
return [];
}
if (info.role === "user") {
const text = parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
@@ -1165,6 +1209,7 @@ function buildOpenCodeReplayTimelineEvents(
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeSessionTimeline,
buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey,
@@ -1240,9 +1285,14 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
const acquisition = await this.runtime.acquireServer({
force: false,
env: launchContext?.env,
env: withOpenCodePaseoToolingEnv({
env: launchContext?.env,
launchContext,
runtime: paseoToolingRuntime,
}),
});
const { url } = acquisition.server;
const client = this.runtime.createClient({
@@ -1267,6 +1317,10 @@ export class OpenCodeAgentClient implements AgentClient {
}
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
launchContext,
sessionId: session.id,
});
return new OpenCodeAgentSession(
openCodeConfig,
@@ -1277,6 +1331,7 @@ export class OpenCodeAgentClient implements AgentClient {
acquisition.release,
options?.persistSession,
launchContext?.agentId,
unbindPaseoToolingSession,
);
} catch (error) {
acquisition.release();
@@ -1302,7 +1357,15 @@ export class OpenCodeAgentClient implements AgentClient {
cwd,
};
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
const acquisition = await this.runtime.acquireServer({
force: false,
env: withOpenCodePaseoToolingEnv({
env: launchContext?.env,
launchContext,
runtime: paseoToolingRuntime,
}),
});
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
@@ -1311,6 +1374,10 @@ export class OpenCodeAgentClient implements AgentClient {
try {
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
launchContext,
sessionId: handle.sessionId,
});
return new OpenCodeAgentSession(
openCodeConfig,
@@ -1321,6 +1388,7 @@ export class OpenCodeAgentClient implements AgentClient {
acquisition.release,
undefined,
launchContext?.agentId,
unbindPaseoToolingSession,
);
} catch (error) {
acquisition.release();
@@ -1591,6 +1659,9 @@ export interface OpenCodeEventTranslationState {
sessionTotalCostUsd?: number;
streamedPartKeys: Set<string>;
emittedStructuredMessageIds: Set<string>;
compactionSummaryMessageIds: Set<string>;
emittedCompactionPartIds: Set<string>;
suppressAssistantMessagesUntilIdle?: { active: boolean };
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
@@ -1951,6 +2022,11 @@ export function translateOpenCodeEvent(
function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void {
state.streamedPartKeys.clear();
state.partTypes.clear();
state.compactionSummaryMessageIds.clear();
state.emittedCompactionPartIds.clear();
if (state.suppressAssistantMessagesUntilIdle) {
state.suppressAssistantMessagesUntilIdle.active = false;
}
}
function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
@@ -2263,6 +2339,14 @@ function appendOpenCodeMessageUpdated(
if (info.role !== "assistant") {
return;
}
if (state.suppressAssistantMessagesUntilIdle?.active) {
state.compactionSummaryMessageIds.add(info.id);
return;
}
if (isOpenCodeCompactionSummaryMessage(info)) {
state.compactionSummaryMessageIds.add(info.id);
return;
}
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
@@ -2320,6 +2404,15 @@ function appendOpenCodeMessagePartUpdated(
const messageRole = state.messageRoles.get(part.messageID);
state.partTypes.set(part.id, part.type);
if (state.compactionSummaryMessageIds.has(part.messageID)) {
return;
}
if (shouldSuppressOpenCodeAssistantPart(part, messageRole, state)) {
state.compactionSummaryMessageIds.add(part.messageID);
return;
}
if (part.type === "text") {
appendOpenCodeTextPart(part, messageRole, state, events);
return;
@@ -2336,6 +2429,10 @@ function appendOpenCodeMessagePartUpdated(
return;
}
if (part.type === "compaction") {
if (state.emittedCompactionPartIds.has(part.id)) {
return;
}
state.emittedCompactionPartIds.add(part.id);
events.push({
type: "timeline",
provider: "opencode",
@@ -2361,6 +2458,18 @@ function appendOpenCodeMessagePartUpdated(
}
}
function shouldSuppressOpenCodeAssistantPart(
part: Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
messageRole: OpenCodeMessageRole | undefined,
state: OpenCodeEventTranslationState,
): boolean {
return (
state.suppressAssistantMessagesUntilIdle?.active === true &&
part.type === "text" &&
messageRole !== "user"
);
}
function appendOpenCodeTextPart(
part: Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
@@ -2691,6 +2800,9 @@ class OpenCodeAgentSession implements AgentSession {
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>();
private compactionSummaryMessageIds = new Set<string>();
private emittedCompactionPartIds = new Set<string>();
private suppressAssistantMessagesUntilIdle = { active: false };
/** Tracks the type of each part by ID, learned from message.part.updated events. */
private partTypes = new Map<string, string>();
private availableModesCache: AgentMode[] | null = null;
@@ -2718,6 +2830,7 @@ class OpenCodeAgentSession implements AgentSession {
releaseServer?: () => void,
persistSession = true,
private readonly agentId?: string,
private readonly unbindPaseoToolingSession?: () => void,
) {
this.config = config;
this.client = client;
@@ -2874,6 +2987,7 @@ class OpenCodeAgentSession implements AgentSession {
const parts = buildOpenCodePromptParts(prompt);
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
this.suppressAssistantMessagesUntilIdle.active = false;
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined;
@@ -2895,6 +3009,7 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") {
this.suppressAssistantMessagesUntilIdle.active = true;
void this.client.session
.summarize({
sessionID: this.sessionId,
@@ -2903,6 +3018,7 @@ class OpenCodeAgentSession implements AgentSession {
})
.then((response) => {
if (response.error) {
this.suppressAssistantMessagesUntilIdle.active = false;
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2911,15 +3027,11 @@ class OpenCodeAgentSession implements AgentSession {
},
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
return;
})
.catch((error) => {
this.suppressAssistantMessagesUntilIdle.active = false;
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -3491,6 +3603,7 @@ class OpenCodeAgentSession implements AgentSession {
await this.deleteProviderSessionIfEphemeral();
this.activeForegroundTurnId = null;
} finally {
this.unbindPaseoToolingSession?.();
this.releaseServer?.();
this.releaseServer = null;
}
@@ -3640,6 +3753,9 @@ class OpenCodeAgentSession implements AgentSession {
sessionTotalCostUsd: this.sessionTotalCostUsd,
streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
compactionSummaryMessageIds: this.compactionSummaryMessageIds,
emittedCompactionPartIds: this.emittedCompactionPartIds,
suppressAssistantMessagesUntilIdle: this.suppressAssistantMessagesUntilIdle,
partTypes: this.partTypes,
subAgentsByCallId: this.subAgentsByCallId,
subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId,
@@ -31,6 +31,8 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
accumulatedUsage: {},
streamedPartKeys: new Set(),
emittedStructuredMessageIds: new Set(),
compactionSummaryMessageIds: new Set(),
emittedCompactionPartIds: new Set(),
partTypes: new Map(),
};
}
@@ -801,6 +803,107 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("does not render OpenCode compaction summaries as assistant messages", () => {
const state = createState();
const loading = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "compaction-part-1",
sessionID: "session-1",
messageID: "message-compaction-user-1",
type: "compaction",
auto: true,
},
},
},
state,
);
translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-compaction-summary-1",
sessionID: "session-1",
role: "assistant",
mode: "compaction",
agent: "compaction",
summary: true,
},
},
},
state,
);
const summaryText = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "summary-text-part-1",
sessionID: "session-1",
messageID: "message-compaction-summary-1",
type: "text",
text: "## Goal\n- Preserve context while continuing the task.",
time: { start: 1, end: 2 },
},
},
},
state,
);
const duplicateLoading = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "compaction-part-1",
sessionID: "session-1",
messageID: "message-compaction-user-1",
type: "compaction",
auto: true,
tail_start_id: "message-tail-1",
},
},
},
state,
);
const completed = translateOpenCodeEvent(
{
type: "session.compacted",
properties: {
sessionID: "session-1",
},
},
state,
);
expect([...loading, ...summaryText, ...duplicateLoading, ...completed]).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
it("emits compaction completed timeline items from session.compacted", () => {
const state = createState();
@@ -0,0 +1,103 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureOpenCodePaseoToolingRuntime } from "./paseo-tooling.js";
describe("OpenCode Paseo tooling plugin", () => {
const originalPaseoHome = process.env.PASEO_HOME;
afterEach(() => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
vi.restoreAllMocks();
});
test("normalizes OpenCode string arguments before executing Paseo tools", async () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "opencode-paseo-tooling-test-"));
process.env.PASEO_HOME = paseoHome;
const runtime = ensureOpenCodePaseoToolingRuntime();
const postedBodies: unknown[] = [];
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
const pathname = new URL(url.toString()).pathname;
if (pathname === "/api/paseo-tooling/manifest") {
return new Response(
JSON.stringify({
tools: [
{
name: "list_agents",
description: "List agents",
inputJsonSchema: {
type: "object",
properties: {
includeArchived: { type: "boolean", default: false },
sinceHours: { type: "integer", default: 48 },
statuses: {
type: "array",
items: { type: "string", enum: ["running", "idle"] },
},
limit: { type: "integer", default: 50 },
},
additionalProperties: false,
},
},
],
}),
{ status: 200 },
);
}
postedBodies.push(JSON.parse(String(init?.body)) as unknown);
return new Response(JSON.stringify({ output: "ok" }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
try {
const plugin = (await import(
`${pathToFileURL(runtime.pluginPath).href}?test=${Date.now()}`
)) as {
server: (
input: unknown,
options: { baseUrl: string; token: string },
) => Promise<{
tool: Record<
string,
{ execute: (args: unknown, context: { sessionID: string }) => Promise<string> }
>;
}>;
};
const server = await plugin.server({}, { baseUrl: "http://127.0.0.1:6767", token: "t" });
await server.tool.paseo_list_agents?.execute(
{
includeArchived: "False",
sinceHours: "",
statuses: '["running"]',
limit: "5",
},
{ sessionID: "session-1" },
);
expect(postedBodies).toEqual([
{
provider: "opencode",
sessionId: "session-1",
tool: "list_agents",
input: {
includeArchived: false,
statuses: ["running"],
limit: 5,
},
},
]);
} finally {
rmSync(paseoHome, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,276 @@
import { createRequire } from "node:module";
import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
const PASEO_OPENCODE_PLUGIN_FILENAME = "paseo-tooling-plugin.mjs";
export interface OpenCodePaseoToolingRuntime {
pluginPath: string;
}
function resolvePaseoHome(): string {
return process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
}
function resolveRuntimeDirectory(): string {
return path.join(resolvePaseoHome(), "opencode");
}
export function ensureOpenCodePaseoToolingRuntime(): OpenCodePaseoToolingRuntime {
const dir = resolveRuntimeDirectory();
mkdirSync(dir, { recursive: true });
const pluginPath = path.join(dir, PASEO_OPENCODE_PLUGIN_FILENAME);
const require = createRequire(import.meta.url);
const zodImportUrl = pathToFileURL(require.resolve("zod")).href;
writeFileSync(pluginPath, createOpenCodePaseoToolingPluginSource(zodImportUrl), "utf8");
return { pluginPath };
}
export function withOpenCodePaseoToolingEnv(params: {
env?: Record<string, string>;
launchContext?: AgentLaunchContext;
runtime: OpenCodePaseoToolingRuntime;
}): Record<string, string> | undefined {
const tooling = params.launchContext?.paseoTooling;
if (!tooling?.httpBaseUrl) {
return params.env;
}
const pluginSpec = [
pathToFileURL(params.runtime.pluginPath).href,
{
baseUrl: tooling.httpBaseUrl,
token: tooling.token,
},
];
return {
...params.env,
OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
params.env?.OPENCODE_CONFIG_CONTENT,
pluginSpec,
),
};
}
export function bindOpenCodePaseoToolingSession(params: {
launchContext?: AgentLaunchContext;
sessionId: string;
}): (() => void) | undefined {
return params.launchContext?.paseoTooling?.bindProviderSession({
provider: "opencode",
sessionId: params.sessionId,
});
}
function mergeOpenCodeConfigContent(existing: string | undefined, pluginSpec: unknown[]): string {
const base = parseConfigContent(existing);
const existingPlugins = Array.isArray(base.plugin) ? base.plugin : [];
const pluginUrl = pluginSpec[0];
const withoutExistingPaseoPlugin = existingPlugins.filter((entry) => {
const spec = Array.isArray(entry) ? entry[0] : entry;
return spec !== pluginUrl;
});
return JSON.stringify({
...base,
plugin: [...withoutExistingPaseoPlugin, pluginSpec],
});
}
function parseConfigContent(existing: string | undefined): Record<string, unknown> {
if (!existing?.trim()) {
return {};
}
try {
const parsed = JSON.parse(existing) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function createOpenCodePaseoToolingPluginSource(zodImportUrl: string): string {
return `
import { z } from ${JSON.stringify(zodImportUrl)};
export async function server(_input, options) {
const manifestResponse = await fetch(new URL("/api/paseo-tooling/manifest", options.baseUrl), {
headers: { authorization: "Bearer " + options.token },
});
if (!manifestResponse.ok) {
throw new Error("Failed to load Paseo tooling manifest: " + manifestResponse.status);
}
const manifest = await manifestResponse.json();
const tools = {};
for (const item of manifest.tools ?? []) {
const id = "paseo_" + item.name;
tools[id] = {
description: item.description,
args: jsonSchemaObjectToZodShape(item.inputJsonSchema),
execute: async (args, context) => {
const input = normalizeJsonSchemaInput(args, item.inputJsonSchema);
const response = await fetch(new URL("/api/paseo-tooling/execute", options.baseUrl), {
method: "POST",
headers: {
authorization: "Bearer " + options.token,
"content-type": "application/json",
},
body: JSON.stringify({
provider: "opencode",
sessionId: context.sessionID,
tool: item.name,
input,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error ?? "Paseo tool failed: " + response.status);
}
return typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? payload, null, 2);
},
};
}
return { tool: tools };
}
function jsonSchemaObjectToZodShape(schema) {
const properties = schema?.properties && typeof schema.properties === "object" ? schema.properties : {};
const required = new Set(Array.isArray(schema?.required) ? schema.required : []);
const shape = {};
for (const [key, value] of Object.entries(properties)) {
let parsed = jsonSchemaToZod(value);
if (!required.has(key)) parsed = parsed.optional();
shape[key] = parsed;
}
return shape;
}
function jsonSchemaToZod(schema) {
if (!schema || typeof schema !== "object") return z.any();
if (schema.anyOf) return unionToZod(schema.anyOf);
if (schema.oneOf) return unionToZod(schema.oneOf);
if (schema.const !== undefined) return z.literal(schema.const);
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
if (schema.enum.length === 1) return z.literal(schema.enum[0]);
if (schema.enum.every((item) => typeof item === "string")) return z.enum(schema.enum);
return z.union(schema.enum.map((item) => z.literal(item)));
}
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const nullable = types.includes("null");
const type = types.find((item) => item !== "null");
let result;
switch (type) {
case "string":
result = z.string();
if (typeof schema.minLength === "number") result = result.min(schema.minLength);
if (typeof schema.maxLength === "number") result = result.max(schema.maxLength);
break;
case "integer":
result = z.number().int();
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
break;
case "number":
result = z.number();
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
break;
case "boolean":
result = z.boolean();
break;
case "array":
result = z.array(jsonSchemaToZod(schema.items));
break;
case "object":
result = z.object(jsonSchemaObjectToZodShape(schema));
break;
default:
result = z.any();
break;
}
if (schema.description && typeof result.describe === "function") result = result.describe(schema.description);
return nullable ? result.nullable() : result;
}
function normalizeJsonSchemaInput(input, schema) {
if (!schema || typeof schema !== "object") return input;
if (schema.anyOf) return normalizeUnionInput(input, schema.anyOf);
if (schema.oneOf) return normalizeUnionInput(input, schema.oneOf);
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const type = types.find((item) => item !== "null");
if (typeof input === "string" && !types.includes("string")) {
const trimmed = input.trim();
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined;
}
switch (type) {
case "boolean":
if (typeof input === "string") {
const normalized = input.trim().toLowerCase();
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
}
return input;
case "integer":
case "number":
if (typeof input === "string" && input.trim() !== "" && /^-?\\d+(\\.\\d+)?$/.test(input.trim())) {
return Number(input);
}
return input;
case "array": {
const value = parseJsonString(input);
if (!Array.isArray(value)) return value;
return value.map((item) => normalizeJsonSchemaInput(item, schema.items)).filter((item) => item !== undefined);
}
case "object": {
const value = parseJsonString(input);
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const normalized = {};
for (const [key, propertySchema] of Object.entries(properties)) {
if (!(key in value)) continue;
const next = normalizeJsonSchemaInput(value[key], propertySchema);
if (next !== undefined || required.has(key)) normalized[key] = next;
}
return normalized;
}
default:
return input;
}
}
function normalizeUnionInput(input, schemas) {
const preferred = schemas.find((item) => item?.type && item.type !== "null") ?? schemas[0];
return normalizeJsonSchemaInput(input, preferred);
}
function parseJsonString(value) {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function unionToZod(items) {
const nonNull = items.filter((item) => item?.type !== "null");
const nullable = nonNull.length !== items.length;
const parsed = nonNull.map(jsonSchemaToZod);
const result = parsed.length === 0 ? z.null() : parsed.length === 1 ? parsed[0] : z.union(parsed);
return nullable ? result.nullable() : result;
}
`;
}
@@ -1,4 +1,4 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
@@ -92,6 +92,7 @@ export class TestOpenCodeClient {
sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
sessionPromptAsyncResponse: OpenCodeResponse = {};
sessionSummarizeEvents: OpenCodeEvent[] = [];
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
sessionUpdateResponse: OpenCodeResponse = {};
private readonly queuedEventStream = createQueuedEventStream();
@@ -208,6 +209,9 @@ export class TestOpenCodeClient {
},
summarize: async (parameters: unknown) => {
this.calls.sessionSummarize.push(parameters);
for (const event of this.sessionSummarizeEvents) {
this.emitEvent(event);
}
return this.sessionSummarizeResponse;
},
update: async (parameters: unknown) => {
@@ -2,7 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
import pino from "pino";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
@@ -693,12 +693,202 @@ describe("PiRpcAgentClient", () => {
];
await expect(session.listCommands()).resolves.toEqual([
{
name: "compact",
description: "Manually compact the session context",
argumentHint: "[instructions]",
},
{
name: "autocompact",
description: "Toggle automatic context compaction",
argumentHint: "[on|off|toggle]",
},
{ name: "review", description: "Review changes", argumentHint: "" },
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
]);
});
test("lists Pi compact even when RPC get_commands omits built-in slash commands", async () => {
const { pi, session } = await createSession();
pi.latestSession().commands = [
{ name: "review", description: "Review changes", source: "extension" },
];
await expect(session.listCommands()).resolves.toContainEqual({
name: "compact",
description: "Manually compact the session context",
argumentHint: "[instructions]",
});
await expect(session.listCommands()).resolves.toContainEqual({
name: "autocompact",
description: "Toggle automatic context compaction",
argumentHint: "[on|off|toggle]",
});
});
test("preserves known argument hints when RPC get_commands returns built-in slash commands", async () => {
const { pi, session } = await createSession();
pi.latestSession().commands = [
{ name: "compact", description: "Compact from RPC", source: "extension" },
{ name: "autocompact", description: "Auto compact from RPC", source: "extension" },
];
await expect(session.listCommands()).resolves.toEqual([
{
name: "compact",
description: "Compact from RPC",
argumentHint: "[instructions]",
},
{
name: "autocompact",
description: "Auto compact from RPC",
argumentHint: "[on|off|toggle]",
},
]);
});
test("executes Pi compact through RPC instead of prompt text", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact focus on tests");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(fakeSession.compactRequests).toEqual([{ customInstructions: "focus on tests" }]);
expect(fakeSession.prompts).toEqual([]);
expect(events).toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "compaction", status: "loading", trigger: "manual" },
},
{
type: "timeline",
provider: "pi",
item: { type: "compaction", status: "completed", trigger: "manual" },
},
]);
});
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emitCompactEnd = false;
fakeSession.compactError = new Error("summarizer failed");
const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(events).toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "compaction", status: "loading", trigger: "manual" },
},
{
type: "timeline",
provider: "pi",
item: { type: "compaction", status: "completed", trigger: "manual" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "assistant_message",
text: "[Error] Failed to compact context: summarizer failed",
},
},
]);
});
test("executes Pi autocompact through RPC instead of prompt text", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact off");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(fakeSession.setAutoCompactionRequests).toEqual([false]);
expect(fakeSession.prompts).toEqual([]);
expect(events).toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "Auto-compaction disabled." },
},
]);
});
test("rejects unknown Pi autocompact mode instead of toggling", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact banana");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(fakeSession.setAutoCompactionRequests).toEqual([]);
expect(events).toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "assistant_message",
text: "[Error] Usage: /autocompact [on|off|toggle]",
},
},
]);
});
test("toggles Pi autocompact through current RPC state", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.state.autoCompactionEnabled = false;
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(fakeSession.setAutoCompactionRequests).toEqual([true]);
expect(events).toContainEqual({
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "Auto-compaction enabled." },
});
});
test("rejects Pi autocompact toggle when current RPC state is unavailable", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
delete fakeSession.state.autoCompactionEnabled;
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact");
const events: AgentStreamEvent[] = [];
expect(handler).not.toBeNull();
await handler?.run({ emit: (event) => events.push(event) });
expect(fakeSession.setAutoCompactionRequests).toEqual([]);
expect(events).toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "assistant_message",
text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.",
},
},
]);
});
test("rewinds conversation through the Pi tree navigation bridge", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().capturedUserEntries = [
@@ -30,6 +30,7 @@ import {
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import { runProviderTurn } from "../provider-runner.js";
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
import {
checkProviderLaunchAvailable,
resolveProviderLaunch,
@@ -86,6 +87,19 @@ const QUESTION_COMMENT_HEADER = "Comment";
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{
name: "compact",
description: "Manually compact the session context",
argumentHint: "[instructions]",
},
{
name: "autocompact",
description: "Toggle automatic context compaction",
argumentHint: "[on|off|toggle]",
},
];
const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -205,6 +219,13 @@ interface ExtensionUiMappingOptions {
allowFreeform?: boolean;
}
interface PiSlashCommandInvocation {
commandName: string;
args?: string;
}
type AutoCompactMode = boolean | "toggle" | "unknown";
function normalizePiModelLabel(label: string): string {
return label.trim().replace(/[_\s]+/g, " ");
}
@@ -247,6 +268,20 @@ function normalizePiThinkingOption(value: string | null | undefined): PiThinking
return isPiThinkingLevel(value) ? value : null;
}
function parseAutoCompactMode(value: string | undefined): AutoCompactMode {
const mode = (value ?? "toggle").trim().toLowerCase();
if (mode === "on" || mode === "true" || mode === "enable" || mode === "enabled") {
return true;
}
if (mode === "off" || mode === "false" || mode === "disable" || mode === "disabled") {
return false;
}
if (mode === "toggle") {
return "toggle";
}
return "unknown";
}
function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) {
const mappedOption = {
id: option.id,
@@ -922,6 +957,9 @@ export class PiRpcAgentSession implements AgentSession {
private readonly seenUserEntryIds = new Set<string>();
private readonly pendingUserMessages: PendingPiUserMessage[] = [];
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
private state: PiSessionState;
private closed = false;
@@ -1127,11 +1165,46 @@ export class PiRpcAgentSession implements AgentSession {
async listCommands(): Promise<AgentSlashCommand[]> {
const commands = await this.runtimeSession.getCommands();
return commands.map((command) => ({
name: command.name,
description: command.description ?? command.source,
argumentHint: "",
}));
const mappedCommands = new Map<string, AgentSlashCommand>(
PI_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]),
);
for (const command of commands) {
const knownCommand = mappedCommands.get(command.name);
mappedCommands.set(command.name, {
name: command.name,
description: command.description ?? command.source,
argumentHint: knownCommand?.argumentHint ?? "",
});
}
return [...mappedCommands.values()];
}
tryHandleOutOfBand(
prompt: AgentPromptInput,
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
if (typeof prompt !== "string") {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed) {
return null;
}
const commandName = parsed.commandName.toLowerCase();
if (commandName === "compact") {
return {
run: async ({ emit }) => {
await this.executeCompactCommand(parsed.args, emit);
},
};
}
if (commandName === "autocompact") {
return {
run: async ({ emit }) => {
await this.executeAutoCompactCommand(parsed.args, emit);
},
};
}
return null;
}
async setModel(modelId: string | null): Promise<void> {
@@ -1172,6 +1245,128 @@ export class PiRpcAgentSession implements AgentSession {
return this.activeTurnId ?? undefined;
}
private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
return null;
}
const withoutPrefix = trimmed.slice(1);
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
const commandName =
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
if (!commandName || commandName.includes("/")) {
return null;
}
const rawArgs =
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
}
private async executeCompactCommand(
customInstructions: string | undefined,
emit: (event: AgentStreamEvent) => void,
): Promise<void> {
if (this.outOfBandCompactionEmit) {
throw new Error("A Pi compact command is already running");
}
this.outOfBandCompactionEmit = emit;
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
await this.runtimeSession.compact(customInstructions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
this.outOfBandCompactionEmit === emit &&
this.outOfBandCompactionStarted &&
!this.outOfBandCompactionCompleted
) {
this.emitCompactionTimeline({
turnId: undefined,
item: {
type: "compaction",
status: "completed",
trigger: "manual",
},
});
}
emit({
type: "timeline",
provider: PI_PROVIDER,
item: {
type: "assistant_message",
text: `[Error] Failed to compact context: ${message}`,
},
});
} finally {
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
}
}
}
private async executeAutoCompactCommand(
mode: string | undefined,
emit: (event: AgentStreamEvent) => void,
): Promise<void> {
let enabled = parseAutoCompactMode(mode);
if (enabled === "unknown") {
emit({
type: "timeline",
provider: PI_PROVIDER,
item: {
type: "assistant_message",
text: "[Error] Usage: /autocompact [on|off|toggle]",
},
});
return;
}
if (enabled === "toggle") {
const state = await this.runtimeSession.getState();
if (typeof state.autoCompactionEnabled !== "boolean") {
emit({
type: "timeline",
provider: PI_PROVIDER,
item: {
type: "assistant_message",
text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.",
},
});
return;
}
enabled = !state.autoCompactionEnabled;
}
try {
await this.runtimeSession.setAutoCompaction(enabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emit({
type: "timeline",
provider: PI_PROVIDER,
item: {
type: "assistant_message",
text: `[Error] Failed to set auto-compaction: ${message}`,
},
});
return;
}
this.state = {
...this.state,
autoCompactionEnabled: enabled,
};
emit({
type: "timeline",
provider: PI_PROVIDER,
item: {
type: "assistant_message",
text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.`,
},
});
}
private async requestEntryCapture(reason: string): Promise<void> {
const requestId = randomUUID();
const resultPromise = this.waitForExtensionResult(requestId);
@@ -1433,9 +1628,7 @@ export class PiRpcAgentSession implements AgentSession {
return;
}
case "compaction_start":
this.emit({
type: "timeline",
provider: PI_PROVIDER,
this.emitCompactionTimeline({
turnId,
item: {
type: "compaction",
@@ -1445,13 +1638,12 @@ export class PiRpcAgentSession implements AgentSession {
});
return;
case "compaction_end":
this.emit({
type: "timeline",
provider: PI_PROVIDER,
this.emitCompactionTimeline({
turnId,
item: {
type: "compaction",
status: "completed",
trigger: event.reason === "manual" ? "manual" : "auto",
},
});
return;
@@ -1463,6 +1655,37 @@ export class PiRpcAgentSession implements AgentSession {
}
}
private emitCompactionTimeline(input: {
turnId: string | undefined;
item: Extract<AgentStreamEvent, { type: "timeline" }>["item"];
}): void {
const emitOutOfBand = this.outOfBandCompactionEmit;
if (emitOutOfBand && input.item.type === "compaction") {
if (input.item.status === "loading") {
this.outOfBandCompactionStarted = true;
}
if (input.item.status === "completed") {
this.outOfBandCompactionCompleted = true;
}
}
const event: AgentStreamEvent = {
type: "timeline",
provider: PI_PROVIDER,
...(emitOutOfBand ? {} : { turnId: input.turnId }),
item: input.item,
};
if (emitOutOfBand) {
emitOutOfBand(event);
if (input.item.type === "compaction" && input.item.status === "completed") {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
}
return;
}
this.emit(event);
}
private handleMessageUpdate(
event: Extract<PiAgentSessionEvent, { type: "message_update" }>,
turnId: string | undefined,
@@ -1616,18 +1839,19 @@ export class PiRpcAgentClient implements AgentClient {
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
const launchConfig = withPaseoToolingMcpServer(config, launchContext);
const mcpConfig = await this.prepareMcpConfig(launchConfig.cwd, launchConfig.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
cwd: config.cwd,
model: config.model,
cwd: launchConfig.cwd,
model: launchConfig.model,
thinkingOptionId:
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
normalizePiThinkingOption(launchConfig.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
systemPrompt: composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
launchConfig.systemPrompt,
launchConfig.daemonAppendSystemPrompt,
),
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
@@ -1641,7 +1865,7 @@ export class PiRpcAgentClient implements AgentClient {
try {
return new PiRpcAgentSession({
runtimeSession,
config,
config: launchConfig,
initialState: await runtimeSession.getState(),
capabilities: withPiMcpCapability(mcpConfig !== null),
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
@@ -1657,7 +1881,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) {
@@ -1667,7 +1891,8 @@ 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);
const launchConfig = withPaseoToolingMcpServer(resumeConfig.config, launchContext);
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, launchConfig.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
@@ -1677,8 +1902,8 @@ export class PiRpcAgentClient implements AgentClient {
model: resumeConfig.model,
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
systemPrompt: composeSystemPromptParts(
resumeConfig.config.systemPrompt,
resumeConfig.config.daemonAppendSystemPrompt,
launchConfig.systemPrompt,
launchConfig.daemonAppendSystemPrompt,
),
mcpConfigPath: mcpConfig?.path,
extensionPaths: [paseoExtension.path],
@@ -1691,7 +1916,7 @@ export class PiRpcAgentClient implements AgentClient {
try {
return new PiRpcAgentSession({
runtimeSession,
config: resumeConfig.config,
config: launchConfig,
initialState: await runtimeSession.getState(),
capabilities: withPiMcpCapability(mcpConfig !== null),
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
@@ -129,6 +129,17 @@ class PiCliRuntimeSession implements PiRuntimeSession {
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
}
async compact(customInstructions?: string): Promise<void> {
await this.request({
type: "compact",
...(customInstructions ? { customInstructions } : {}),
});
}
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.request({ type: "set_auto_compaction", enabled });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
@@ -79,6 +79,7 @@ export interface PiSessionState {
thinkingLevel: PiThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
autoCompactionEnabled?: boolean;
sessionFile?: string;
sessionId: string;
sessionName?: string;
@@ -111,6 +112,8 @@ export interface PiRpcSlashCommand {
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "compact"; customInstructions?: string }
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
| { id?: string; type: "abort" }
| { id?: string; type: "get_state" }
| { id?: string; type: "get_messages" }
@@ -37,6 +37,8 @@ export interface PiRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
@@ -51,6 +51,8 @@ export class FakePi implements PiRuntime {
export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
readonly treeNavigationRequests: string[] = [];
@@ -69,6 +71,8 @@ export class FakePiSession implements PiRuntimeSession {
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
compactError: Error | null = null;
emitCompactEnd = true;
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
@@ -79,6 +83,7 @@ export class FakePiSession implements PiRuntimeSession {
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
autoCompactionEnabled: true,
sessionFile: launch.session ?? "/tmp/pi-session",
sessionId: "pi-session-1",
messageCount: 0,
@@ -102,6 +107,25 @@ export class FakePiSession implements PiRuntimeSession {
this.handleEntryCaptureCommand(message);
}
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
if (this.compactError) {
throw this.compactError;
}
}
async setAutoCompaction(enabled: boolean): Promise<void> {
this.setAutoCompactionRequests.push(enabled);
this.state = {
...this.state,
autoCompactionEnabled: enabled,
};
}
async abort(): Promise<void> {
this.abortRequested = true;
}
@@ -0,0 +1,39 @@
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
const PASEO_MCP_SERVER_NAME = "paseo";
const PASEO_MCP_PATHNAME = "/mcp/agents";
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
const mcpServers = config.mcpServers;
if (!mcpServers) {
return config;
}
const paseoServer = mcpServers[PASEO_MCP_SERVER_NAME];
if (!paseoServer || !isInternalPaseoMcpServer(paseoServer)) {
return config;
}
const nextMcpServers = { ...mcpServers };
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
const next = { ...config };
if (Object.keys(nextMcpServers).length > 0) {
next.mcpServers = nextMcpServers;
} else {
delete next.mcpServers;
}
return next;
}
function isInternalPaseoMcpServer(config: McpServerConfig): boolean {
if (config.type !== "http" && config.type !== "sse") {
return false;
}
try {
return new URL(config.url).pathname === PASEO_MCP_PATHNAME;
} catch {
return false;
}
}
@@ -233,15 +233,15 @@ describe("archiveIfSafe", () => {
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
});
test("does nothing when the upstream status is unknown", async () => {
test("archives when the PR is merged and the upstream branch was deleted", async () => {
const harness = createHarness({
getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: null } }),
getSnapshot: async () =>
createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1);
});
test("does nothing when the cwd is not a Paseo-owned worktree", async () => {
@@ -78,10 +78,10 @@ export async function archiveIfSafe(input: {
return;
}
if (snapshot.git.isDirty === true || snapshot.git.aheadOfOrigin === null) {
if (snapshot.git.isDirty === true) {
return;
}
if (snapshot.git.aheadOfOrigin > 0) {
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
return;
}
+113 -74
View File
@@ -99,6 +99,10 @@ import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createPaseoToolRegistry } from "./agent/paseo-tooling/registry.js";
import { mountPaseoToolingHttpAdapter } from "./agent/paseo-tooling/http-adapter.js";
import { PaseoToolingRuntime } from "./agent/paseo-tooling/runtime.js";
import type { PaseoToolingRegistryOptions } from "./agent/paseo-tooling/registry.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
@@ -165,6 +169,14 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
).toString();
}
function createDaemonHttpBaseUrl(listenTarget: ListenTarget | null): string | null {
if (!listenTarget || listenTarget.type !== "tcp") {
return null;
}
const host = resolveAgentMcpClientHost(listenTarget.host);
return `http://${formatHostForHttpUrl(host)}:${listenTarget.port}`;
}
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {
@@ -542,10 +554,13 @@ export async function createPaseoDaemon(
extraClients: config.agentClients,
});
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const paseoToolingRuntime = new PaseoToolingRuntime();
paseoToolingRuntime.setEnabled(config.mcpInjectIntoAgents !== false);
const agentManager = new AgentManager({
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
paseoToolingRuntime,
appendSystemPrompt: config.appendSystemPrompt,
logger,
});
@@ -689,79 +704,85 @@ export async function createPaseoDaemon(
const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
github,
workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
createPaseoWorktree: async (input, serviceOptions) => {
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
);
void emitOptions;
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
const buildPaseoToolingRegistryOptions = (
callerAgentId?: string,
): PaseoToolingRegistryOptions => ({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
github,
workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
createPaseoWorktree: async (input, serviceOptions) => {
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
},
input,
serviceOptions,
);
},
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
);
void emitOptions;
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
},
input,
serviceOptions,
);
},
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer(
buildPaseoToolingRegistryOptions(callerAgentId),
);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
@@ -870,6 +891,16 @@ export async function createPaseoDaemon(
void runAgentMcpRequest(req, res);
};
mountPaseoToolingHttpAdapter({
app,
registry: createPaseoToolRegistry({
...buildPaseoToolingRegistryOptions(),
agentScopedTools: true,
}),
runtime: paseoToolingRuntime,
logger,
});
app.post(agentMcpRoute, handleAgentMcpRequest);
app.get(agentMcpRoute, handleAgentMcpRequest);
app.delete(agentMcpRoute, handleAgentMcpRequest);
@@ -917,10 +948,18 @@ export async function createPaseoDaemon(
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
const httpBaseUrl = createDaemonHttpBaseUrl(boundListenTarget);
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
paseoToolingRuntime.setEndpoints({
mcpBaseUrl: agentMcpBaseUrl,
httpBaseUrl: config.mcpInjectIntoAgents === false ? null : httpBaseUrl,
});
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
paseoToolingRuntime.setEnabled(value !== false);
paseoToolingRuntime.setEndpoints({
mcpBaseUrl: value ? mcpBaseUrl : null,
httpBaseUrl: value ? httpBaseUrl : null,
});
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
@@ -137,6 +137,44 @@ test("createAgent without an initial prompt returns an idle snapshot", async ()
}
});
test("DaemonClient uploads file bytes to daemon temp storage", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
const result = await client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload-e2e",
chunkSize: 5,
});
expect(result).toEqual({
requestId: "req-upload-e2e",
file: {
type: "uploaded_file",
id: "upload_req-upload-e2e",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"),
},
error: null,
});
await expect(readFile(result.file?.path ?? "", "utf8")).resolves.toBe("hello world");
} finally {
await client.close();
await daemon.close();
}
});
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
@@ -1,4 +1,4 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
@@ -24,6 +24,7 @@ process.env.PASEO_SUPERVISED = "0";
const PI_TEST_TIMEOUT_MS = 240_000;
const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model;
const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free";
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
@@ -31,6 +32,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function writePiCompactionSettings(
cwd: string,
compaction: { enabled: boolean; reserveTokens?: number; keepRecentTokens?: number },
): void {
mkdirSync(path.join(cwd, ".pi"), { recursive: true });
writeFileSync(path.join(cwd, ".pi/settings.json"), JSON.stringify({ compaction }, null, 2));
}
function createPiClient(): AgentClient {
return createRealProviderClient("pi", pino({ level: "silent" }));
}
@@ -68,6 +81,27 @@ async function fetchCanonicalTimeline(
return timeline.entries.map((entry) => entry.item);
}
async function waitForTimelineItem(
client: DaemonClient,
agentId: string,
predicate: (item: AgentTimelineItem) => boolean,
timeoutMs = PI_TEST_TIMEOUT_MS,
): Promise<AgentTimelineItem> {
const deadline = Date.now() + timeoutMs;
let lastItems: AgentTimelineItem[] = [];
while (Date.now() < deadline) {
lastItems = await fetchCanonicalTimeline(client, agentId);
const item = lastItems.find(predicate);
if (item) {
return item;
}
await sleep(250);
}
throw new Error(
`Timed out waiting for Pi timeline item. Last timeline: ${JSON.stringify(lastItems)}`,
);
}
async function withConnectedPiDaemon(
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
@@ -101,6 +135,200 @@ beforeEach((context) => {
}
});
test(
"real Pi daemon lists Paseo-handled compact slash commands",
async () => {
const cwd = tmpCwd("pi-compact-commands-");
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-compact-commands",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
});
const result = await client.listCommands(agent.id);
expect(result.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "compact",
description: "Manually compact the session context",
}),
expect.objectContaining({
name: "autocompact",
description: "Toggle automatic context compaction",
}),
]),
);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"real Pi daemon executes manual compact out-of-band instead of prompt text",
async () => {
const cwd = tmpCwd("pi-manual-compact-");
try {
writePiCompactionSettings(cwd, {
enabled: true,
keepRecentTokens: 1,
});
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-manual-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
await client.sendMessage(agent.id, "/compact summarize this e2e run");
await waitForTimelineItem(
client,
agent.id,
(item) => item.type === "compaction" && item.status === "completed",
);
const items = await fetchCanonicalTimeline(client, agent.id);
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "compaction",
status: "loading",
trigger: "manual",
}),
expect.objectContaining({
type: "compaction",
status: "completed",
}),
]),
);
expect(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
).toBe(false);
expect(
items.some(
(item) => item.type === "assistant_message" && item.text.includes("Failed to compact"),
),
).toBe(false);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"real Pi daemon toggles auto-compaction out-of-band instead of prompt text",
async () => {
const cwd = tmpCwd("pi-autocompact-toggle-");
try {
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-autocompact-toggle",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "/autocompact off");
await waitForTimelineItem(
client,
agent.id,
(item) => item.type === "assistant_message" && item.text === "Auto-compaction disabled.",
);
await client.sendMessage(agent.id, "/autocompact");
await waitForTimelineItem(
client,
agent.id,
(item) => item.type === "assistant_message" && item.text === "Auto-compaction enabled.",
);
const items = await fetchCanonicalTimeline(client, agent.id);
expect(
items.some((item) => item.type === "user_message" && item.text.includes("/autocompact")),
).toBe(false);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"real Pi daemon surfaces automatic threshold compaction events",
async () => {
const cwd = tmpCwd("pi-auto-compact-");
try {
writePiCompactionSettings(cwd, {
enabled: true,
reserveTokens: 126_000,
keepRecentTokens: 1,
});
await withConnectedPiDaemon(async ({ client }) => {
const agent = await client.createAgent({
cwd,
title: "pi-auto-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status, JSON.stringify(finish)).toBe("idle");
await waitForTimelineItem(
client,
agent.id,
(item) => item.type === "compaction" && item.status === "completed",
);
const items = await fetchCanonicalTimeline(client, agent.id);
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "compaction",
status: "loading",
trigger: "auto",
}),
expect.objectContaining({
type: "compaction",
status: "completed",
}),
]),
);
expect(
items.some(
(item) =>
item.type === "assistant_message" && item.text.includes("Auto-compaction failed"),
),
).toBe(false);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
PI_TEST_TIMEOUT_MS,
);
test(
"bash tool call records completed shell detail and output",
async () => {
@@ -19,12 +19,28 @@ type SupervisorLifecycleMessage =
reason?: string;
};
interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface BootstrapResult {
paseoHome: string;
logger: ReturnType<typeof createRootLogger>;
config: ReturnType<typeof loadConfig>;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") {
return true;
}
return false;
}
}
function bootstrapFromEnvironment(): BootstrapResult {
try {
const paseoHome = resolvePaseoHome();
@@ -150,6 +166,46 @@ async function main() {
beginShutdown("restart lifecycle intent", { successExitCode: 0 });
};
const installSupervisorLivenessGuard = () => {
if (typeof process.send !== "function") {
return;
}
const supervisorPid = process.ppid;
let lastSupervisorHeartbeatAt = Date.now();
let supervisorShutdownRequested = false;
const requestSupervisorShutdown = (reason: string) => {
if (supervisorShutdownRequested) {
return;
}
supervisorShutdownRequested = true;
beginShutdown(reason);
};
process.on("message", (message: unknown) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
(message as SupervisorHeartbeatMessage).type === "paseo:supervisor-heartbeat"
) {
lastSupervisorHeartbeatAt = Date.now();
}
});
process.on("disconnect", () => requestSupervisorShutdown("supervisor disconnect"));
const timer = setInterval(() => {
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
if (ipcConnected === false || !isPidAlive(supervisorPid) || heartbeatExpired) {
requestSupervisorShutdown("supervisor disconnect");
}
}, 1000);
timer.unref();
};
installSupervisorLivenessGuard();
try {
daemon = await createPaseoDaemon(
{
@@ -0,0 +1,254 @@
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { FileUploadStore } from "./index.js";
const tempDirs: string[] = [];
describe("file uploads", () => {
afterEach(() => {
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("stores chunked upload bytes and returns an uploaded-file attachment", async () => {
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome });
uploads.beginUpload({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
});
await expect(uploads.receiveFrame(uploadBegins("req-upload"))).resolves.toBeNull();
await expect(uploads.receiveFrame(uploadChunk("req-upload", "hello"))).resolves.toBeNull();
await expect(uploads.receiveFrame(uploadChunk("req-upload", " world"))).resolves.toBeNull();
const path = join(paseoHome, "uploads", "upload_req-upload", "notes.txt");
await expect(uploads.receiveFrame(uploadEnds("req-upload"))).resolves.toEqual({
type: "file.upload.response",
payload: {
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path,
},
error: null,
},
});
expect(readFileSync(path, "utf8")).toBe("hello world");
});
it("rejects chunks beyond the declared size and removes the partial file", async () => {
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome });
uploads.beginUpload({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 5,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-overflow",
});
await expect(uploads.receiveFrame(uploadBegins("req-overflow"))).resolves.toBeNull();
const uploadDir = join(paseoHome, "uploads", "upload_req-overflow");
const path = join(uploadDir, "notes.txt");
await expect(uploads.receiveFrame(uploadChunk("req-overflow", "hello!"))).resolves.toEqual({
type: "file.upload.response",
payload: {
requestId: "req-overflow",
file: null,
error: "Upload exceeded declared size: expected 5, received 6.",
},
});
expect(existsSync(path)).toBe(false);
expect(existsSync(uploadDir)).toBe(false);
});
it("preserves chunk order when frames arrive before earlier disk writes finish", async () => {
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome });
uploads.beginUpload({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-queued",
});
const results = await Promise.all([
uploads.receiveFrame(uploadBegins("req-queued")),
uploads.receiveFrame(uploadChunk("req-queued", "hello")),
uploads.receiveFrame(uploadChunk("req-queued", " world")),
uploads.receiveFrame(uploadEnds("req-queued")),
]);
expect(results.slice(0, 3)).toEqual([null, null, null]);
expect(results[3]?.payload.error).toBeNull();
expect(readFileSync(join(paseoHome, "uploads", "upload_req-queued", "notes.txt"), "utf8")).toBe(
"hello world",
);
});
it("replaces duplicate upload starts without letting the old stale timeout evict the replacement", async () => {
vi.useFakeTimers();
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome, staleUploadTimeoutMs: 50 });
uploads.beginUpload({
type: "file.upload.request",
fileName: "old.txt",
mimeType: "text/plain",
size: 3,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-duplicate",
});
await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull();
await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "old"))).resolves.toBeNull();
await vi.advanceTimersByTimeAsync(25);
uploads.beginUpload({
type: "file.upload.request",
fileName: "new.txt",
mimeType: "text/plain",
size: 3,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-duplicate",
});
await vi.advanceTimersByTimeAsync(30);
const path = join(paseoHome, "uploads", "upload_req-duplicate_2", "new.txt");
await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull();
await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "new"))).resolves.toBeNull();
await expect(uploads.receiveFrame(uploadEnds("req-duplicate"))).resolves.toEqual({
type: "file.upload.response",
payload: {
requestId: "req-duplicate",
file: {
type: "uploaded_file",
id: "upload_req-duplicate_2",
fileName: "new.txt",
mimeType: "text/plain",
size: 3,
path,
},
error: null,
},
});
expect(readFileSync(path, "utf8")).toBe("new");
});
it("keeps an active upload alive beyond the initial stale timeout", async () => {
vi.useFakeTimers();
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome, staleUploadTimeoutMs: 50 });
uploads.beginUpload({
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-slow-active",
});
await vi.advanceTimersByTimeAsync(25);
await expect(uploads.receiveFrame(uploadBegins("req-slow-active"))).resolves.toBeNull();
await vi.advanceTimersByTimeAsync(30);
await expect(uploads.receiveFrame(uploadChunk("req-slow-active", "hello"))).resolves.toBeNull();
await vi.advanceTimersByTimeAsync(30);
await expect(
uploads.receiveFrame(uploadChunk("req-slow-active", " world")),
).resolves.toBeNull();
const path = join(paseoHome, "uploads", "upload_req-slow-active", "notes.txt");
await expect(uploads.receiveFrame(uploadEnds("req-slow-active"))).resolves.toEqual({
type: "file.upload.response",
payload: {
requestId: "req-slow-active",
file: {
type: "uploaded_file",
id: "upload_req-slow-active",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path,
},
error: null,
},
});
expect(readFileSync(path, "utf8")).toBe("hello world");
});
});
function makePaseoHome(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-")));
tempDirs.push(root);
return root;
}
function uploadBegins(requestId: string): FileTransferFrame {
return decodeUploadFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
}),
);
}
function uploadChunk(requestId: string, text: string): FileTransferFrame {
return decodeUploadFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: new TextEncoder().encode(text),
}),
);
}
function uploadEnds(requestId: string): FileTransferFrame {
return decodeUploadFrame(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
);
}
function decodeUploadFrame(bytes: Uint8Array): FileTransferFrame {
const frame = decodeFileTransferFrame(bytes);
if (!frame) {
throw new Error("Expected file transfer frame");
}
return frame;
}
@@ -0,0 +1,220 @@
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type { FileUploadRequest, FileUploadResponse } from "../messages.js";
interface FileUploadStoreOptions {
paseoHome: string;
staleUploadTimeoutMs?: number;
}
interface PendingUpload {
requestId: string;
id: string;
attempt: number;
fileName: string;
mimeType: string;
size: number;
path: string;
receivedBytes: number;
started: boolean;
staleTimeout: ReturnType<typeof setTimeout>;
queue: Promise<void>;
}
export class FileUploadStore {
private static readonly defaultStaleUploadTimeoutMs = 10 * 60 * 1000;
private readonly paseoHome: string;
private readonly staleUploadTimeoutMs: number;
private readonly pending = new Map<string, PendingUpload>();
constructor(options: FileUploadStoreOptions) {
this.paseoHome = options.paseoHome;
this.staleUploadTimeoutMs =
options.staleUploadTimeoutMs ?? FileUploadStore.defaultStaleUploadTimeoutMs;
}
beginUpload(request: FileUploadRequest): void {
const existingUpload = this.pending.get(request.requestId);
if (existingUpload) {
this.clearPendingUpload(existingUpload);
void existingUpload.queue.then(() => this.removeUploadDirectory(existingUpload));
}
const fileName = sanitizeFileName(request.fileName);
const attempt = existingUpload ? existingUpload.attempt + 1 : 1;
const id = buildUploadId(request.requestId, attempt);
const uploadDir = join(this.paseoHome, "uploads", id);
const upload: PendingUpload = {
requestId: request.requestId,
id,
attempt,
fileName,
mimeType: request.mimeType,
size: request.size,
path: join(uploadDir, fileName),
receivedBytes: 0,
started: false,
staleTimeout: this.createStaleUploadTimeout(request.requestId),
queue: Promise.resolve(),
};
this.pending.set(request.requestId, upload);
}
async receiveFrame(frame: FileTransferFrame): Promise<FileUploadResponse | null> {
const upload = this.pending.get(frame.requestId);
if (!upload) {
return null;
}
this.refreshStaleUploadTimeout(upload);
const operation = upload.queue.then(() => this.applyFrame(upload, frame));
upload.queue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
private async applyFrame(
upload: PendingUpload,
frame: FileTransferFrame,
): Promise<FileUploadResponse | null> {
if (this.pending.get(upload.requestId) !== upload) {
return null;
}
try {
if (frame.opcode === FileTransferOpcode.FileBegin) {
await this.startWriting(upload);
return null;
}
if (frame.opcode === FileTransferOpcode.FileChunk) {
await this.writeChunk(upload, frame.payload);
return null;
}
return await this.completeUpload(upload);
} catch (error) {
await this.removeFailedUpload(upload);
return buildUploadResponse(upload, getErrorMessage(error));
}
}
private async startWriting(upload: PendingUpload): Promise<void> {
await mkdir(join(this.paseoHome, "uploads", upload.id), { recursive: true });
await writeFile(upload.path, new Uint8Array());
upload.started = true;
}
private async writeChunk(upload: PendingUpload, bytes: Uint8Array): Promise<void> {
if (!upload.started) {
throw new Error("Upload chunks arrived before file begin.");
}
const nextReceivedBytes = upload.receivedBytes + bytes.byteLength;
if (nextReceivedBytes > upload.size) {
throw new Error(
`Upload exceeded declared size: expected ${upload.size}, received ${nextReceivedBytes}.`,
);
}
await appendFile(upload.path, bytes);
upload.receivedBytes += bytes.byteLength;
}
private async completeUpload(upload: PendingUpload): Promise<FileUploadResponse> {
this.clearPendingUpload(upload);
if (upload.receivedBytes !== upload.size) {
await this.removeUploadDirectory(upload);
return buildUploadResponse(
upload,
`Upload size mismatch: expected ${upload.size}, received ${upload.receivedBytes}.`,
);
}
return buildUploadResponse(upload, null);
}
private createStaleUploadTimeout(requestId: string): ReturnType<typeof setTimeout> {
const timeout = setTimeout(() => {
this.expireStaleUpload(requestId);
}, this.staleUploadTimeoutMs);
timeout.unref?.();
return timeout;
}
private refreshStaleUploadTimeout(upload: PendingUpload): void {
clearTimeout(upload.staleTimeout);
upload.staleTimeout = this.createStaleUploadTimeout(upload.requestId);
}
private expireStaleUpload(requestId: string): void {
const upload = this.pending.get(requestId);
if (!upload) {
return;
}
this.clearPendingUpload(upload);
const cleanup = upload.queue.then(
() => this.removeUploadDirectory(upload),
() => this.removeUploadDirectory(upload),
);
upload.queue = cleanup.then(
() => undefined,
() => undefined,
);
}
private clearPendingUpload(upload: PendingUpload): void {
clearTimeout(upload.staleTimeout);
if (this.pending.get(upload.requestId) === upload) {
this.pending.delete(upload.requestId);
}
}
private async removeFailedUpload(upload: PendingUpload): Promise<void> {
this.clearPendingUpload(upload);
await this.removeUploadDirectory(upload);
}
private async removeUploadDirectory(upload: PendingUpload): Promise<void> {
await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }).catch(
() => undefined,
);
}
}
function buildUploadResponse(upload: PendingUpload, error: string | null): FileUploadResponse {
return {
type: "file.upload.response",
payload: {
requestId: upload.requestId,
file: error
? null
: {
type: "uploaded_file",
id: upload.id,
fileName: upload.fileName,
mimeType: upload.mimeType,
size: upload.size,
path: upload.path,
},
error,
},
};
}
function sanitizeUploadId(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "file";
}
function buildUploadId(requestId: string, attempt: number): string {
const baseId = `upload_${sanitizeUploadId(requestId)}`;
return attempt === 1 ? baseId : `${baseId}_${attempt}`;
}
function sanitizeFileName(value: string): string {
const name = basename(value)
.replace(/[^a-zA-Z0-9._ -]/g, "_")
.trim();
return name.length > 0 && name !== "." && name !== ".." ? name : "upload";
}
@@ -95,6 +95,54 @@ describe("persistence hooks", () => {
});
});
test("buildConfigOverrides drops persisted internal paseo MCP server", () => {
const record = createRecord({
config: {
modeId: "default",
model: "gpt-5.4-mini",
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
},
custom: {
type: "stdio",
command: "custom-mcp",
},
},
},
});
expect(buildConfigOverrides(record).mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
});
test("buildConfigOverrides preserves user-provided paseo MCP server", () => {
const record = createRecord({
config: {
modeId: "default",
model: "gpt-5.4-mini",
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
},
});
expect(buildConfigOverrides(record).mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
});
test("buildSessionConfig accepts providers from the canonical manifest", () => {
const record = createRecord({
provider: "claude",
@@ -1,4 +1,5 @@
import type { AgentManager } from "./agent/agent-manager.js";
import { stripInternalPaseoMcpServer } from "./agent/runtime-mcp-config.js";
import type {
AgentPersistenceHandle,
AgentProvider,
@@ -49,7 +50,8 @@ export function attachAgentStoragePersistence(
}
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
return stripInternalPaseoMcpServer({
provider: record.provider,
cwd: record.cwd,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
@@ -58,7 +60,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
extra: record.config?.extra ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
mcpServers: record.config?.mcpServers ?? undefined,
};
});
}
export function buildSessionConfig(
@@ -71,7 +73,7 @@ export function buildSessionConfig(
return null;
}
const overrides = buildConfigOverrides(record);
return {
return stripInternalPaseoMcpServer({
provider: record.provider,
cwd: record.cwd,
modeId: overrides.modeId,
@@ -81,7 +83,7 @@ export function buildSessionConfig(
extra: overrides.extra,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
};
});
}
export function isStoredAgentProviderAvailable(
+2 -1
View File
@@ -236,6 +236,7 @@ interface SessionForTestOptions {
providerSnapshotManager?: ProviderSnapshotManager;
stt?: SessionOptions["stt"];
voice?: SessionOptions["voice"];
paseoHome?: string;
messages?: unknown[];
binaryMessages?: Uint8Array[];
}
@@ -272,7 +273,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
logger,
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
paseoHome: "/tmp/paseo-home",
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
agentManager: asAgentManager({
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
+26 -3
View File
@@ -16,6 +16,7 @@ import {
type SessionOutboundMessage,
type FileExplorerRequest,
type FileDownloadTokenRequest,
type FileUploadRequest,
type GitSetupOptions,
type CheckoutRenameBranchRequest,
type StartWorkspaceScriptRequest,
@@ -30,10 +31,12 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
import {
type BinaryFrame,
encodeFileTransferFrame,
FileTransferOpcode,
type TerminalStreamFrame,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { FileUploadStore } from "./file-upload/index.js";
import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import { TTSManager } from "./agent/tts-manager.js";
@@ -809,6 +812,7 @@ export class Session {
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly fileUploads: FileUploadStore;
private readonly workspaceDirectory: WorkspaceDirectory;
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
@@ -877,6 +881,7 @@ export class Session {
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.fileUploads = new FileUploadStore({ paseoHome });
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
@@ -2108,6 +2113,9 @@ export class Session {
return this.handleProjectIconRequest(msg);
case "file_download_token_request":
return this.handleFileDownloadTokenRequest(msg);
case "file.upload.request":
this.handleFileUploadRequest(msg);
return undefined;
default:
return undefined;
}
@@ -2212,8 +2220,12 @@ export class Session {
this.peakInflightRequests = this.inflightRequests;
}
public handleBinaryFrame(frame: TerminalStreamFrame): void {
this.terminalController.handleBinaryFrame(frame);
public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise<void> {
if (binaryFrame.kind === "file_transfer") {
await this.handleFileTransferFrame(binaryFrame.frame);
return;
}
this.terminalController.handleBinaryFrame(binaryFrame.frame);
}
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
@@ -5823,6 +5835,17 @@ export class Session {
}
}
private handleFileUploadRequest(request: FileUploadRequest): void {
this.fileUploads.beginUpload(request);
}
private async handleFileTransferFrame(frame: FileTransferFrame): Promise<void> {
const response = await this.fileUploads.receiveFrame(frame);
if (response) {
this.emit(response);
}
}
/**
* Handle project icon request for a given cwd
*/
@@ -156,6 +156,7 @@ export async function initializeLocalSpeechServices(params: {
const workerClient = localConfig
? new LocalSpeechWorkerClient({
logger,
config: {
modelsDir: localConfig.modelsDir,
voiceSttModel: localModels.voiceLocalSttModel,
@@ -20,6 +20,8 @@ import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
class FakeLocalSpeechWorker extends EventEmitter {
public connected = true;
public killed = false;
public pid = 12345;
public readonly stderr = new EventEmitter() as NodeJS.ReadableStream;
public readonly sent: LocalSpeechWorkerRequest[] = [];
public disconnects = 0;
public kills = 0;
@@ -88,9 +90,9 @@ class PausedIpcWorker {
}
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
on(
event: "message" | "exit",
event: "message" | "close",
listener:
| ((message: LocalSpeechWorkerToParentMessage) => void)
| ((code: number | null, signal: NodeJS.Signals | null) => void),
@@ -103,6 +105,7 @@ class PausedIpcWorker {
function createClient(options?: { idleTtlMs?: number }) {
const workers: FakeLocalSpeechWorker[] = [];
const client = new LocalSpeechWorkerClient({
logger: pino({ level: "silent" }),
config: {
modelsDir: "/tmp/models",
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
@@ -120,6 +123,19 @@ function createClient(options?: { idleTtlMs?: number }) {
return { client, workers };
}
function createCapturingLogger(): { logger: pino.Logger; records: Array<Record<string, unknown>> } {
const records: Array<Record<string, unknown>> = [];
const logger = pino(
{ level: "trace" },
{
write(line: string) {
records.push(JSON.parse(line) as Record<string, unknown>);
},
},
);
return { logger, records };
}
async function waitForMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
@@ -217,6 +233,7 @@ describe("LocalSpeechWorkerClient", () => {
it("does not surface real IPC backpressure when replaying native-sized dictation frames", async () => {
const workers: PausedIpcWorker[] = [];
const client = new LocalSpeechWorkerClient({
logger: pino({ level: "silent" }),
config: {
modelsDir: "/tmp/models",
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
@@ -257,6 +274,80 @@ describe("LocalSpeechWorkerClient", () => {
}
});
it("logs worker exit details and includes actionable context in the surfaced error", async () => {
const { logger, records } = createCapturingLogger();
const workers: FakeLocalSpeechWorker[] = [];
const client = new LocalSpeechWorkerClient({
logger,
config: {
modelsDir: "/tmp/models",
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
voiceTtsModel: "kokoro-en-v0_19",
},
forkWorker: () => {
const worker = new FakeLocalSpeechWorker();
workers.push(worker);
return worker;
},
});
const provider = new WorkerBackedSpeechToTextProvider(client, "dictationStt");
const session = provider.createSession({ logger: pino({ level: "silent" }) });
const connect = session.connect();
workers[0].emit("exit", null, "SIGABRT");
workers[0].stderr.emit("data", "dyld: Library not loaded: libsherpa-onnx-c-api.dylib");
workers[0].emit("close", null, "SIGABRT");
await expect(connect).rejects.toThrow(
"Local speech worker exited (signal SIGABRT) while handling session.create (dictationStt). Last stderr: dyld: Library not loaded: libsherpa-onnx-c-api.dylib",
);
const exitRecord = records.find((record) => record.msg === "Local speech worker exited");
expect(exitRecord).toMatchObject({
workerPid: 12345,
signal: "SIGABRT",
stderrTail: "dyld: Library not loaded: libsherpa-onnx-c-api.dylib",
pendingRequests: [expect.objectContaining({ type: "session.create", kind: "dictationStt" })],
});
});
it("does not log intentional shutdowns as worker crashes", async () => {
const { logger, records } = createCapturingLogger();
const workers: FakeLocalSpeechWorker[] = [];
const client = new LocalSpeechWorkerClient({
logger,
config: {
modelsDir: "/tmp/models",
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
voiceTtsModel: "kokoro-en-v0_19",
},
forkWorker: () => {
const worker = new FakeLocalSpeechWorker();
workers.push(worker);
return worker;
},
});
const synthesize = client.synthesizeSpeech("hello");
workers[0].respond(workers[0].sent[0], {
audio: bufferToWorkerBytes(Buffer.from([1])),
format: "pcm;rate=24000",
});
await synthesize;
client.shutdown();
workers[0].emit("close", null, "SIGTERM");
expect(records.find((record) => record.msg === "Local speech worker exited")).toBeUndefined();
expect(
records.find((record) => record.msg === "Local speech worker closed after shutdown"),
).toMatchObject({
workerPid: 12345,
signal: "SIGTERM",
});
});
it("forwards VAD session events through the shared worker", async () => {
const { client, workers } = createClient();
const provider = new WorkerBackedTurnDetectionProvider(client);
@@ -28,6 +28,8 @@ import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_LOCAL_SAMPLE_RATE = 16000;
const STDERR_TAIL_MAX_CHARS = 8000;
const USER_ERROR_STDERR_MAX_CHARS = 1000;
type LocalSpeechWorkerRequestInput = LocalSpeechWorkerRequest extends infer Request
? Request extends LocalSpeechWorkerRequest
@@ -38,21 +40,27 @@ type LocalSpeechWorkerRequestInput = LocalSpeechWorkerRequest extends infer Requ
interface LocalSpeechWorkerProcess {
connected: boolean;
killed: boolean;
pid?: number;
stderr?: Readable | null;
send(message: LocalSpeechWorkerRequest, callback: (error: Error | null) => void): boolean;
disconnect(): void;
kill(): boolean;
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
type: LocalSpeechWorkerRequest["type"];
summary: Record<string, unknown>;
startedAt: number;
}
interface LocalSpeechWorkerClientOptions {
config: LocalSpeechWorkerConfig;
logger: pino.Logger;
requestTimeoutMs?: number;
idleTtlMs?: number;
forkWorker?: () => LocalSpeechWorkerProcess;
@@ -90,7 +98,7 @@ function forkLocalSpeechWorker(): LocalSpeechWorkerProcess {
env,
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
stdio: ["ignore", "ignore", "pipe", "ipc"],
}) as LocalSpeechWorkerProcess;
}
@@ -100,8 +108,75 @@ function isResponse(
return message.type === "response";
}
function truncateStart(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
return value.slice(value.length - maxChars);
}
function summarizeWorkerRequest(message: LocalSpeechWorkerRequest): Record<string, unknown> {
switch (message.type) {
case "session.create":
return { kind: message.kind, sessionId: message.sessionId };
case "session.append":
return { sessionId: message.sessionId, audioBytes: message.audio.byteLength };
case "session.commit":
case "session.clear":
case "session.flush":
case "session.reset":
case "session.close":
return { sessionId: message.sessionId };
case "stt.transcribe":
return {
model: message.model,
audioBytes: message.audio.byteLength,
format: message.format,
};
case "tts.synthesize":
return { textLength: message.text.length };
}
}
function formatExitStatus(code: number | null, signal: NodeJS.Signals | null): string {
const parts: string[] = [];
if (code !== null) {
parts.push(`code ${code}`);
}
if (signal) {
parts.push(`signal ${signal}`);
}
return parts.length > 0 ? parts.join(", ") : "unknown exit status";
}
function formatPendingRequestForMessage(request: Record<string, unknown>): string {
const type = typeof request.type === "string" ? request.type : "unknown request";
const kind = typeof request.kind === "string" ? ` (${request.kind})` : "";
return `${type}${kind}`;
}
function buildWorkerExitMessage(params: {
code: number | null;
signal: NodeJS.Signals | null;
pendingRequests: Array<Record<string, unknown>>;
stderrTail: string;
}): string {
const pending =
params.pendingRequests.length > 0
? ` while handling ${params.pendingRequests
.slice(0, 3)
.map(formatPendingRequestForMessage)
.join(", ")}`
: "";
const stderr = params.stderrTail
? ` Last stderr: ${truncateStart(params.stderrTail, USER_ERROR_STDERR_MAX_CHARS)}`
: " Check daemon.log and macOS DiagnosticReports for Paseo Voice crash details.";
return `Local speech worker exited (${formatExitStatus(params.code, params.signal)})${pending}.${stderr}`;
}
export class LocalSpeechWorkerClient {
private readonly config: LocalSpeechWorkerConfig;
private readonly logger: pino.Logger;
private readonly requestTimeoutMs: number;
private readonly idleTtlMs: number;
private readonly forkWorker: () => LocalSpeechWorkerProcess;
@@ -109,11 +184,16 @@ export class LocalSpeechWorkerClient {
private readonly activeSessionIds = new Set<string>();
private readonly sessionEmitters = new Map<string, EventEmitter>();
private worker: LocalSpeechWorkerProcess | null = null;
private workerPid: number | null = null;
private stderrTail = "";
private stderrLineBuffer = "";
private inFlightRequests = 0;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private readonly intentionalWorkerCloses = new WeakSet<LocalSpeechWorkerProcess>();
constructor(options: LocalSpeechWorkerClientOptions) {
this.config = options.config;
this.logger = options.logger.child({ component: "local-speech-worker-client" });
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
this.forkWorker = options.forkWorker ?? forkLocalSpeechWorker;
@@ -214,7 +294,9 @@ export class LocalSpeechWorkerClient {
this.sessionEmitters.clear();
const worker = this.worker;
this.worker = null;
this.workerPid = null;
if (worker && !worker.killed) {
this.intentionalWorkerCloses.add(worker);
try {
worker.disconnect();
} catch {
@@ -232,6 +314,7 @@ export class LocalSpeechWorkerClient {
const worker = this.ensureWorker();
const requestId = randomUUID();
const message = { ...input, requestId } as LocalSpeechWorkerRequest;
const requestSummary = summarizeWorkerRequest(message);
this.inFlightRequests++;
this.clearIdleTimer();
@@ -246,6 +329,9 @@ export class LocalSpeechWorkerClient {
resolve: (value) => resolve(value as T),
reject,
timeout,
type: message.type,
summary: requestSummary,
startedAt: Date.now(),
});
worker.send(message, (error) => {
@@ -271,11 +357,44 @@ export class LocalSpeechWorkerClient {
}
const worker = this.forkWorker();
this.worker = worker;
this.workerPid = worker.pid ?? null;
this.stderrTail = "";
this.stderrLineBuffer = "";
this.logger.info(
{
workerPid: this.workerPid,
modelsDir: this.config.modelsDir,
voiceSttModel: this.config.voiceSttModel,
dictationSttModel: this.config.dictationSttModel,
voiceTtsModel: this.config.voiceTtsModel,
},
"Local speech worker spawned",
);
worker.stderr?.on("data", (chunk: Buffer | string) => this.handleWorkerStderr(chunk));
worker.on("message", (message) => this.handleWorkerMessage(message));
worker.on("exit", () => this.handleWorkerExit());
worker.on("close", (code, signal) => this.handleWorkerExit(worker, code, signal));
return worker;
}
private handleWorkerStderr(chunk: Buffer | string): void {
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
this.stderrTail = truncateStart(this.stderrTail + text, STDERR_TAIL_MAX_CHARS);
this.stderrLineBuffer += text;
const lines = this.stderrLineBuffer.split(/\r?\n/);
this.stderrLineBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
this.logger.warn(
{ workerPid: this.workerPid, stderr: trimmed },
"Local speech worker stderr",
);
}
}
private handleWorkerMessage(message: LocalSpeechWorkerToParentMessage): void {
if (isResponse(message)) {
const pending = this.pendingRequests.get(message.requestId);
@@ -317,13 +436,84 @@ export class LocalSpeechWorkerClient {
}
}
private handleWorkerExit(): void {
this.worker = null;
private handleWorkerExit(
worker: LocalSpeechWorkerProcess,
code: number | null,
signal: NodeJS.Signals | null,
): void {
const wasCurrentWorker = this.worker === worker;
const wasIntentionalClose = this.intentionalWorkerCloses.has(worker);
this.intentionalWorkerCloses.delete(worker);
const workerPid = worker.pid ?? (wasCurrentWorker ? this.workerPid : null);
const pendingRequests = this.describePendingRequests();
const activeSessionCount = this.activeSessionIds.size;
const stderrTail = this.getStderrTail();
if (wasIntentionalClose) {
this.logger.info(
{
workerPid,
code,
signal,
pendingRequests,
activeSessionCount,
stderrTail: stderrTail || null,
},
"Local speech worker closed after shutdown",
);
if (wasCurrentWorker) {
this.worker = null;
this.workerPid = null;
}
return;
}
if (!wasCurrentWorker) {
this.logger.warn(
{
workerPid,
code,
signal,
pendingRequests,
activeSessionCount,
stderrTail: stderrTail || null,
},
"Stale local speech worker closed",
);
return;
}
const error = new Error(
buildWorkerExitMessage({
code,
signal,
pendingRequests,
stderrTail,
}),
);
this.logger.error(
{
err: error,
workerPid,
code,
signal,
pendingRequests,
activeSessionCount,
stderrTail: stderrTail || null,
},
"Local speech worker exited",
);
if (wasCurrentWorker) {
this.worker = null;
this.workerPid = null;
}
this.clearIdleTimer();
this.rejectAllPending(new Error("Local speech worker exited"));
this.rejectAllPending(error);
for (const [sessionId, emitter] of this.sessionEmitters) {
if (this.activeSessionIds.has(sessionId)) {
emitter.emit("error", new Error("Local speech worker exited"));
this.emitErrorIfObserved(sessionId, emitter, error, workerPid);
}
}
this.activeSessionIds.clear();
@@ -331,6 +521,24 @@ export class LocalSpeechWorkerClient {
this.inFlightRequests = 0;
}
private describePendingRequests(): Array<Record<string, unknown>> {
const now = Date.now();
return Array.from(this.pendingRequests.entries()).map(([requestId, request]) =>
Object.assign(
{
requestId,
type: request.type,
ageMs: Math.max(0, now - request.startedAt),
},
request.summary,
),
);
}
private getStderrTail(): string {
return truncateStart(this.stderrTail.trim(), STDERR_TAIL_MAX_CHARS);
}
private rejectAllPending(error: Error): void {
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
@@ -344,7 +552,27 @@ export class LocalSpeechWorkerClient {
if (!emitter) {
return;
}
emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
this.emitErrorIfObserved(
sessionId,
emitter,
error instanceof Error ? error : new Error(String(error)),
);
}
private emitErrorIfObserved(
sessionId: string,
emitter: EventEmitter,
error: Error,
workerPid: number | null = this.workerPid,
): void {
if (emitter.listenerCount("error") > 0) {
emitter.emit("error", error);
return;
}
this.logger.warn(
{ err: error, workerPid, sessionId },
"Local speech worker session error had no listener",
);
}
private scheduleIdleShutdownIfReady(): void {
@@ -105,6 +105,7 @@ import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
interface WebSocketServerInternals {
attachSocket(ws: unknown, req: unknown): Promise<void>;
socketMessageQueues: Map<unknown, Promise<void>>;
}
const TEST_DAEMON_VERSION = "1.2.3-test";
@@ -125,9 +126,12 @@ function parseSentEnvelope(data: unknown): z.infer<typeof WireEnvelopeSchema> {
}
const BinaryFrameSchema = z.object({
opcode: z.number(),
slot: z.number(),
payload: z.instanceof(Uint8Array),
kind: z.literal("terminal"),
frame: z.object({
opcode: z.number(),
slot: z.number(),
payload: z.instanceof(Uint8Array),
}),
});
class MockSocket {
@@ -369,7 +373,7 @@ async function attachRelayAndHello(params: {
}) {
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve();
await waitForSocketMessages(params.server, params.socket);
expect(params.socket.sent.length).toBeGreaterThan(0);
const envelope = parseSentEnvelope(params.socket.sent[0]);
expect(envelope.type).toBe("session");
@@ -389,7 +393,7 @@ async function attachDirectAndHello(params: {
createDirectRequest(),
);
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve();
await waitForSocketMessages(params.server, params.socket);
expect(params.socket.sent.length).toBeGreaterThan(0);
const envelope = parseSentEnvelope(params.socket.sent[0]);
expect(envelope.type).toBe("session");
@@ -399,6 +403,13 @@ async function attachDirectAndHello(params: {
return serverInfo!;
}
async function waitForSocketMessages(
server: VoiceAssistantWebSocketServer,
socket: MockSocket,
): Promise<void> {
await asInternals<WebSocketServerInternals>(server).socketMessageQueues.get(socket);
}
describe("relay external socket reconnect behavior", () => {
beforeEach(() => {
sessionMock.instances.length = 0;
@@ -453,7 +464,7 @@ describe("relay external socket reconnect behavior", () => {
}),
),
);
await Promise.resolve();
await waitForSocketMessages(server, socket);
expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0];
@@ -550,7 +561,7 @@ describe("relay external socket reconnect behavior", () => {
},
}),
);
await Promise.resolve();
await waitForSocketMessages(server, socket);
expect(closeCode).toBe(4002);
expect(["Invalid hello", "Session message before hello"]).toContain(closeReason);
@@ -757,10 +768,10 @@ describe("relay external socket reconnect behavior", () => {
}),
),
);
await Promise.resolve();
await waitForSocketMessages(server, socket);
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
const frame = BinaryFrameSchema.parse(session.handleBinaryFrame.mock.calls[0]?.[0]);
const { frame } = BinaryFrameSchema.parse(session.handleBinaryFrame.mock.calls[0]?.[0]);
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
expect(frame.slot).toBe(9);
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
+28 -8
View File
@@ -24,7 +24,7 @@ import {
type WSOutboundMessage,
wrapSessionMessage,
} from "./messages.js";
import { asUint8Array, decodeTerminalStreamFrame } from "@getpaseo/protocol/binary-frames/index";
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
import type { HostnamesConfig } from "./hostnames.js";
import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
@@ -324,6 +324,7 @@ export class VoiceAssistantWebSocketServer {
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
private readonly socketMessageQueues: Map<WebSocketLike, Promise<void>> = new Map();
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly daemonRuntimeConfig:
@@ -1096,7 +1097,7 @@ export class VoiceAssistantWebSocketServer {
private bindSocketHandlers(ws: WebSocketLike): void {
ws.on("message", (...args: unknown[]) => {
const data = args[0] as Buffer | ArrayBuffer | Buffer[] | string;
void this.handleRawMessage(ws, data);
this.enqueueRawMessage(ws, data);
});
ws.on("close", async (...args: unknown[]) => {
@@ -1119,6 +1120,25 @@ export class VoiceAssistantWebSocketServer {
});
}
private enqueueRawMessage(
ws: WebSocketLike,
data: Buffer | ArrayBuffer | Buffer[] | string,
): void {
const previous = this.socketMessageQueues.get(ws) ?? Promise.resolve();
const next = previous.then(
() => this.handleRawMessage(ws, data),
() => this.handleRawMessage(ws, data),
);
this.socketMessageQueues.set(ws, next);
void next
.catch(() => undefined)
.finally(() => {
if (this.socketMessageQueues.get(ws) === next) {
this.socketMessageQueues.delete(ws);
}
});
}
public resolveVoiceSpeakHandler(callerAgentId: string): VoiceSpeakHandler | null {
return this.voiceSpeakHandlers.get(callerAgentId) ?? null;
}
@@ -1298,19 +1318,19 @@ export class VoiceAssistantWebSocketServer {
);
}
private maybeHandleBinaryFrame(params: {
private async maybeHandleBinaryFrame(params: {
ws: WebSocketLike;
buffer: Buffer;
activeConnection: SessionConnection | undefined;
log: pino.Logger;
}): boolean {
}): Promise<boolean> {
const { ws, buffer, activeConnection, log } = params;
const asBytes = asUint8Array(buffer);
if (!asBytes) {
return false;
}
const frame = decodeTerminalStreamFrame(asBytes);
if (!frame) {
const decodedFrame = decodeBinaryFrame(asBytes);
if (!decodedFrame) {
return false;
}
if (!activeConnection) {
@@ -1324,7 +1344,7 @@ export class VoiceAssistantWebSocketServer {
}
return true;
}
activeConnection.session.handleBinaryFrame(frame);
await activeConnection.session.handleBinaryFrame(decodedFrame);
return true;
}
@@ -1369,7 +1389,7 @@ export class VoiceAssistantWebSocketServer {
try {
const buffer = bufferFromWsData(data);
const binaryHandled = this.maybeHandleBinaryFrame({
const binaryHandled = await this.maybeHandleBinaryFrame({
ws,
buffer,
activeConnection,
+6 -4
View File
@@ -2,6 +2,7 @@
"worktree": {
"setup": [
"npm ci",
"cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" ./scripts/dev-home.sh",
"npm run build:server",
"npm run build --workspace=@getpaseo/expo-two-way-audio",
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
@@ -10,14 +11,15 @@
"scripts": {
"daemon": {
"type": "service",
"command": "PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
},
"app": {
"type": "service",
"command": "BROWSER=none EXPO_PUBLIC_LOCAL_DAEMON=\"localhost:${PASEO_SERVICE_DAEMON_PORT}\" npm run start:expo --workspace=@getpaseo/app -- --port $PASEO_PORT"
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT ./scripts/dev-app.sh"
},
"typecheck": {
"command": "npm run typecheck"
"desktop": {
"type": "service",
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
source "$SCRIPT_DIR/dev-home.sh"
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
configure_dev_paseo_home
EXPO_PORT="${EXPO_PORT:-8081}"
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
echo "══════════════════════════════════════════════════════"
echo " Paseo App Dev"
echo "══════════════════════════════════════════════════════"
echo " Metro: http://localhost:${EXPO_PORT}"
echo " Daemon: ${DAEMON_ENDPOINT}"
echo " Home: ${PASEO_HOME}"
echo "══════════════════════════════════════════════════════"
exec cross-env \
BROWSER="${BROWSER:-none}" \
APP_VARIANT=development \
EXPO_PUBLIC_LOCAL_DAEMON="$DAEMON_ENDPOINT" \
npm run start:expo --workspace=@getpaseo/app -- --port "$EXPO_PORT"
+4 -1
View File
@@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
source "$SCRIPT_DIR/dev-home.sh"
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
configure_dev_paseo_home
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
@@ -17,6 +19,7 @@ echo " Paseo Dev Daemon"
echo "══════════════════════════════════════════════════════"
echo " Home: ${PASEO_HOME}"
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
echo " Listen: ${PASEO_LISTEN}"
echo "══════════════════════════════════════════════════════"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
@@ -26,4 +29,4 @@ if [ "${PASEO_SKIP_DEV_SERVER_BUILD:-0}" = "1" ]; then
exec npm run dev:server:watch
fi
exec npm run dev:server
exec sh -c 'npm run build:server-deps && npm run dev:server:watch'
+73 -13
View File
@@ -1,5 +1,9 @@
#!/bin/bash
default_dev_paseo_root() {
git rev-parse --show-toplevel 2>/dev/null || pwd
}
copy_json_tree() {
local source_dir="$1"
local target_dir="$2"
@@ -58,27 +62,83 @@ seed_worktree_paseo_home() {
echo " Seed: copied metadata from ${source_home}"
}
configure_dev_daemon_config() {
if [ -z "${PASEO_LISTEN:-}" ]; then
return
fi
mkdir -p "$PASEO_HOME"
node -e '
const fs = require("fs");
const [path, listen] = [process.argv[1], process.argv[2]];
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
cfg.version = cfg.version || 1;
cfg.daemon = cfg.daemon || {};
cfg.daemon.listen = listen;
cfg.daemon.cors = cfg.daemon.cors || {};
cfg.daemon.cors.allowedOrigins = ["*"];
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
' "$PASEO_HOME/config.json" "$PASEO_LISTEN"
}
resolve_dev_daemon_endpoint() {
if [ -n "${PASEO_DEV_DAEMON_ENDPOINT:-}" ]; then
echo "$PASEO_DEV_DAEMON_ENDPOINT"
return
fi
case "${PASEO_LISTEN:-127.0.0.1:6768}" in
0.0.0.0:*) echo "localhost:${PASEO_LISTEN#0.0.0.0:}" ;;
127.0.0.1:*) echo "localhost:${PASEO_LISTEN#127.0.0.1:}" ;;
*) echo "$PASEO_LISTEN" ;;
esac
}
configure_dev_paseo_home() {
if [ -n "${PASEO_HOME:-}" ]; then
export PASEO_HOME
if [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
seed_worktree_paseo_home "$PASEO_HOME"
fi
mkdir -p "$PASEO_HOME"
if [ "${PASEO_DEV_MANAGED_HOME:-0}" = "1" ] || [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
configure_dev_daemon_config
fi
return
fi
export PASEO_HOME
local git_dir
local git_common_dir
git_dir="$(git rev-parse --git-dir 2>/dev/null || true)"
git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
if [ -n "$git_dir" ] && [ -n "$git_common_dir" ] && [ "$git_dir" != "$git_common_dir" ]; then
local worktree_root
local worktree_name
worktree_root="$(git rev-parse --show-toplevel)"
worktree_name="$(basename "$worktree_root" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')"
PASEO_HOME="$HOME/.paseo-${worktree_name}"
local dev_root
dev_root="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
PASEO_HOME="$dev_root/.dev/paseo-home"
export PASEO_DEV_MANAGED_HOME=1
if [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
seed_worktree_paseo_home "$PASEO_HOME"
return
fi
PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")"
trap "rm -rf '$PASEO_HOME'" EXIT
mkdir -p "$PASEO_HOME"
configure_dev_daemon_config
}
configure_dev_command_env() {
if [ -z "${PASEO_LISTEN:-}" ]; then
if [ -n "${PASEO_SERVICE_DAEMON_PORT:-}" ]; then
export PASEO_LISTEN="0.0.0.0:${PASEO_SERVICE_DAEMON_PORT}"
else
export PASEO_LISTEN="127.0.0.1:6768"
fi
fi
configure_dev_paseo_home
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
if [ "$#" -gt 0 ]; then
configure_dev_command_env
exec "$@"
fi
configure_dev_paseo_home
fi
+1 -40
View File
@@ -1,43 +1,4 @@
#!/bin/bash
set -e
# Ensure node_modules/.bin is in PATH (for when script runs directly)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
source "$SCRIPT_DIR/dev-home.sh"
configure_dev_paseo_home
# Share speech models with the main install to avoid duplicate downloads
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
export PASEO_LOCAL_MODELS_DIR="$HOME/.paseo/models/local-speech"
mkdir -p "$PASEO_LOCAL_MODELS_DIR"
fi
echo "══════════════════════════════════════════════════════"
echo " Paseo Dev"
echo "══════════════════════════════════════════════════════"
echo " Home: ${PASEO_HOME}"
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
echo "══════════════════════════════════════════════════════"
npm run build:server
npm run build --workspace=@getpaseo/expo-two-way-audio
# Configure the daemon for the Portless app origin and let the app bootstrap
# through the daemon's Portless URL instead of a fixed localhost port.
APP_ORIGIN="$(portless get app)"
DAEMON_ENDPOINT="$(portless get daemon | sed -E 's#^https?://##')"
# Allow any origin in dev so Electron on random ports and Portless URLs all work.
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
# the daemon binds to localhost and this script is never used for production.
export PASEO_CORS_ORIGINS="*"
# Run both with concurrently
# BROWSER=none prevents auto-opening browser
# EXPO_PUBLIC_LOCAL_DAEMON configures the app to auto-connect to this daemon
concurrently \
--names "daemon,metro" \
--prefix-colors "cyan,magenta" \
"portless run --name daemon sh -c 'PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \
"BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npm run start:expo --workspace=@getpaseo/app"
exec npm run dev:server