Compare commits

...

26 Commits

Author SHA1 Message Date
Mohamed Boudra
1e5e17f000 chore(release): cut 0.1.69 2026-05-05 19:40:12 +07:00
Mohamed Boudra
20de118373 Add changelog for 0.1.69 2026-05-05 19:39:18 +07:00
Mohamed Boudra
fd4e26ca9b Align script TypeScript lib with server build 2026-05-05 18:54:29 +07:00
Somasundaram Ayyappan
f89e5604d2 fix: normalize Claude AskUserQuestion answers (#755) (#760)
Translate Paseo's header-keyed AskUserQuestion answers into the full question-text keys Claude expects before resolving question permissions.

Also preserve the original question payload when UI callbacks return an answers-only updatedInput, and lock the behavior down with provider-flow regression coverage in claude-agent tests.
2026-05-05 19:50:34 +08:00
Mohamed Boudra
ca3e55813e Keep daemon worker supervision explicit 2026-05-05 18:48:07 +07:00
Mohamed Boudra
3fad128d02 Restart daemon worker crashes in production 2026-05-05 18:42:21 +07:00
Mohamed Boudra
0cb9da17cd Daemon hardening epic 2026-05-05 18:21:10 +07:00
github-actions[bot]
1571f002c7 fix: update lockfile signatures and Nix hash 2026-05-05 09:47:45 +00:00
Mohamed Boudra
84478a2dba chore(release): cut 0.1.68 2026-05-05 16:46:10 +07:00
Mohamed Boudra
93065b20f7 Add changelog for 0.1.68 2026-05-05 16:45:16 +07:00
Mohamed Boudra
bb8762e122 Fix desktop settings first-launch race 2026-05-05 16:43:38 +07:00
Mohamed Boudra
15a2e3bdcb chore(release): cut 0.1.67 2026-05-04 01:43:24 +07:00
Mohamed Boudra
64ba05cea5 Add v0.1.67 changelog entry 2026-05-04 01:42:23 +07:00
Mohamed Boudra
1521ceb381 Surface desktop daemon gate failures instead of silent no-op
The async settings gate added in f0d96f8e called
shouldStartDaemon() inside Promise.resolve(...).then(...) with no
catch. If loadDesktopSettings() rejects (corrupted JSON, FS error),
the daemon never starts, no error renders on the splash, and the
retry button repeats the same silent path.

Add a recordError method to DaemonStartService and an onGateError
callback to startDaemonIfGateAllows / startHostRuntimeBootstrap.
_layout.tsx wires it through so a gate rejection populates
lastError, the splash shows the failure, and retry has signal.
2026-05-04 01:40:47 +07:00
Mohamed Boudra
4338f5b46c Fix workspace reconnect playwright tests after toast move
Test #1 was asserting agent-reconnecting-toast on a workspace with no
agents — but commit 1daa1314 moved the reconnect indicator from the
workspace banner into the per-agent panel toast. Create an idle agent
and navigate to it so the agent panel mounts before dropping the
daemon gate.

Test #2 regex still pinned the old "Connecting to localhost" copy;
commit c534c857 tightened it to just "Connecting" with the hostname in
the description. Match the new title plus the existing offline copies.
2026-05-04 01:18:37 +07:00
Mohamed Boudra
e8a64fb569 ci: build server before desktop tests
f0d96f8e added packages/desktop/src/daemon/daemon-manager.test.ts,
which calls vi.mock("@getpaseo/server", ...). Vite's module
resolution still needs to find the package's main entry, so
desktop CI must build the server (and its dependency chain) before
running the tests. Mirrors the prebuild steps used by typecheck,
server-tests, and playwright.
2026-05-04 00:40:38 +07:00
Mohamed Boudra
cf1f849b68 Update MCP create_agent test to match async branch auto-name
7c85341a moved first-agent branch auto-naming out of the worktree
service (sync slugify) into the workflow layer (async LLM call).
The MCP test stubs createPaseoWorktree, bypassing the workflow,
so the placeholder mnemonic branch is never renamed at this layer.
Replace the obsolete sync-slugify outcome assertion with a
placeholder-presence check; the auto-name logic itself is covered
in worktree-branch-name-generator.test.ts.
2026-05-04 00:31:33 +07:00
Mohamed Boudra
bc0886ad7c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 00:16:08 +07:00
Mohamed Boudra
12fcfe53eb Make structured response generation ephemeral end-to-end
Plumbs persistSession through the structured response pipeline so
metadata, branch name, commit message, and PR text generators no
longer leave provider-side artifacts behind:

- Codex: passes ephemeral: true on thread/start (no rollout file)
- OpenCode: deletes the session via session.delete on close
- Claude: forwards persistSession: false to the SDK query

Adds a real-credentials e2e test per provider that asserts the
generated title is applied AND no persistence artifact is produced
(rollout for Codex, project JSONL for Claude, leftover session for
OpenCode).
2026-05-04 00:10:50 +07:00
github-actions[bot]
a198a33ff5 fix: update lockfile signatures and Nix hash 2026-05-03 16:24:36 +00:00
Mohamed Boudra
7c85341a42 Fix worktree creation and archive flow
Server: drop the synchronous branch-name generator dep, use the
worktree slug as the initial branch, and move first-agent branch
auto-naming to an async post-creation step backed by a structured
LLM call. Guards against renaming when the placeholder branch has
already been changed.

App: make workspace and worktree archive optimistic, rolling back
the local hide if the daemon call fails, and suppress incoming
workspace upserts while an archive is pending.
2026-05-03 23:22:54 +07:00
Mohamed Boudra
c534c857a4 Tighten workspace host connecting copy 2026-05-03 23:01:29 +07:00
Mohamed Boudra
6d8c7c4d2d Fix desktop settings legacy override 2026-05-03 22:57:33 +07:00
Mohamed Boudra
f0d96f8e5a Fix built-in daemon management toggle 2026-05-03 22:02:40 +07:00
Mohamed Boudra
f8d4758e6b Run iOS Fastlane review-submit on macOS
Linux EAS runners don't ship Ruby/Bundler, so `bundle install`
fails with "bundle: command not found". macOS workflow runners
have Ruby 3.2 + Fastlane preinstalled, so switching this job to
runs_on: macos-medium gets us there without a manual install step.
2026-05-03 21:27:24 +07:00
github-actions[bot]
09b498da61 fix: update lockfile signatures and Nix hash 2026-05-03 13:50:32 +00:00
93 changed files with 2822 additions and 1258 deletions

View File

@@ -155,6 +155,15 @@ jobs:
- name: Install dependencies
run: npm install
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop

View File

@@ -1,5 +1,28 @@
# Changelog
## 0.1.69 - 2026-05-05
### Fixed
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
## 0.1.68 - 2026-05-05
### Fixed
- The desktop app no longer fails on first launch after a fresh install.
## 0.1.67 - 2026-05-03
### Fixed
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
- The built-in daemon toggle in desktop settings now actually takes effect.
- Desktop settings no longer reset on app launch after a legacy migration.
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
## 0.1.66 - 2026-05-03
### Fixed

View File

@@ -1,6 +1,10 @@
# Ad-hoc daemon testing
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
## Quick start
@@ -85,7 +89,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-6jn98U9nIh8pK4EseV6WJOIcr7JEkUm9C2NYWDUUMCQ=";
npmDepsHash = "sha256-J0SsiBBQYhsFzmwCg4NLOLhCXS1vqiegfJqPdoHWSKM=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -123,7 +123,7 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production
# Create wrapper for the CLI

27
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.66",
"version": "0.1.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.66",
"version": "0.1.69",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -38445,7 +38445,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38571,10 +38571,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.66",
"@getpaseo/server": "0.1.69",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38616,7 +38616,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.66",
"version": "0.1.69",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38665,7 +38665,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.66",
"version": "0.1.69",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38701,7 +38701,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38727,7 +38727,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38742,12 +38742,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.66",
"@getpaseo/relay": "0.1.66",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -38762,6 +38762,7 @@
"dotenv": "^17.2.3",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
@@ -39199,7 +39200,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.66",
"version": "0.1.69",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

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

View File

@@ -43,6 +43,7 @@ jobs:
name: Submit iOS for App Store review
needs: [submit_ios]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane

View File

@@ -573,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["src/server/index.ts"], {
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,

View File

@@ -1,4 +1,4 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
@@ -133,28 +133,34 @@ test.describe("Workspace navigation regression", () => {
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const repo = await createTempGitRepo("workspace-reconnect-");
try {
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
workspaceIds.add(workspace.workspaceId);
const agent = await createIdleAgent(archiveClient, {
cwd: repo.path,
title: `workspace-reconnect-${Date.now()}`,
});
agentIds.push(agent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: workspace.workspaceId,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceTabVisible(page, agent.id);
await daemonGate.drop();
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
@@ -184,9 +190,13 @@ test.describe("Workspace navigation regression", () => {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
} finally {
daemonGate.restore();
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await repo.cleanup();
}
@@ -213,7 +223,7 @@ test.describe("Workspace navigation regression", () => {
);
await expect(
page.getByText(/Connecting to localhost|localhost is offline|Cannot reach localhost/i),
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("menu-button")).toBeVisible();
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);

View File

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

View File

@@ -50,11 +50,12 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
@@ -319,7 +328,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldUseDesktopDaemon(),
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -346,7 +356,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => {
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;

View File

@@ -55,6 +55,42 @@ describe("startHostRuntimeBootstrap", () => {
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("skips daemon-start when the startup gate resolves false", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => false,
});
await Promise.resolve();
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
const onGateError = vi.fn();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => {
throw new Error("settings file unreadable");
},
onGateError,
});
await vi.waitFor(() => {
expect(onGateError).toHaveBeenCalledTimes(1);
});
expect(daemonStartService.start).not.toHaveBeenCalled();
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;

View File

@@ -11,17 +11,49 @@ export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: boolean;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
if (input.shouldStartDaemon) {
void input.daemonStartService.start();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
});
}
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}): void {
const gate = input.shouldStartDaemon;
if (typeof gate === "boolean") {
if (gate) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve()
.then(() => gate())
.then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return null;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
});
}
export const WELCOME_ROUTE: Href = "/welcome";

View File

@@ -104,9 +104,14 @@ import {
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: workspace.workspaceId,
});
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return snapshot;
}
function restoreOptimisticallyHiddenWorkspace(input: {
serverId: string;
workspaceId: string;
snapshot: WorkspaceDescriptor | null;
}): void {
clearWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (input.snapshot) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
}
}
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
toast.error(message);
});
})();
}, [
archiveWorktree,
isArchiving,
redirectAfterArchive,
toast,
workspace.name,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
}
setIsArchivingWorkspace(true);
const snapshot = hideWorkspaceOptimistically(workspace);
redirectAfterArchive();
void (async () => {
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
snapshot,
});
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
return (
<>
@@ -2120,13 +2144,28 @@ function ProjectBlock({
}
setIsRemovingProject(true);
const snapshots = new Map(
project.workspaces.map((workspace) => [
workspace.workspaceId,
hideWorkspaceOptimistically(workspace),
]),
);
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
void Promise.allSettled(
project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
try {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
restoreOptimisticallyHiddenWorkspace({
serverId,
workspaceId: ws.workspaceId,
snapshot: snapshots.get(ws.workspaceId) ?? null,
});
throw error;
}
}),
).then((results) => {

View File

@@ -42,7 +42,6 @@ import {
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -60,7 +59,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
}
@@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: String(message.payload.id),
});
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
removeWorkspace(serverId, String(message.payload.id));
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId,
workspace,
isArchivePending: isLocalWorktreeArchivePending,
})
) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
return;
}
mergeWorkspaces(serverId, [workspace]);

View File

@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
markWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
id: "/repo/worktree",
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return { ...baseWorkspace, ...input };
}
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
it("suppresses archiving upserts for a locally pending archive", () => {
const isArchivePending = vi.fn(() => true);
describe("workspace archive pending suppression", () => {
it("tracks a locally pending workspace archive by id and directory", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
isArchivePending,
workspaceId: "/repo/worktree",
}),
).toBe(true);
expect(isArchivePending).toHaveBeenCalledWith({
serverId: "server-1",
cwd: "/repo/worktree",
});
});
it("allows archiving upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
isWorkspaceArchivePending({
serverId: "server-1",
workspace: workspace(),
isArchivePending: () => false,
workspaceDirectory: "/repo/worktree",
}),
).toBe(false);
});
it("allows normal upserts while a local archive is pending", () => {
).toBe(true);
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
isArchivePending: () => true,
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
expect(
isWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
}),
).toBe(false);
});
it("suppresses upserts for a locally pending archive", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
it("allows upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace(),
}),
).toBe(false);
});
it("suppresses stale normal upserts while a local archive is pending", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
});

View File

@@ -1,15 +1,96 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
}
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
export function clearWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return;
}
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
if (archives.size === 0) {
pendingWorkspaceArchivesByServer.delete(serverId);
}
}
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
serverId: string;
workspace: WorkspaceDescriptor;
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
}): boolean {
return (
input.workspace.archivingAt !== null &&
input.isArchivePending({
serverId: input.serverId,
cwd: input.workspace.workspaceDirectory,
})
);
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}

View File

@@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({
const daemonCommandMocks = vi.hoisted(() => ({
getCliDaemonStatusMock: vi.fn(),
startDesktopDaemonMock: vi.fn(),
stopDesktopDaemonMock: vi.fn(),
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
shouldUseDesktopDaemon: vi.fn(() => true),
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
}));
@@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => {
settingsState.updateSettings.mockReset();
settingsState.updateSettings.mockResolvedValue();
daemonStatusState.data.status.status = "running";
daemonStatusState.data.status.desktopManaged = true;
daemonStatusState.setStatus.mockReset();
daemonStatusState.refetch.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
status: "running",
desktopManaged: true,
});
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
@@ -297,4 +306,57 @@ describe("LocalDaemonSection", () => {
},
});
});
it("persists paused management before stopping the desktop-managed daemon", async () => {
confirmDialogMock.mockResolvedValue(true);
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
);
});
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
confirmDialogMock.mockResolvedValue(true);
daemonStatusState.data.status.desktopManaged = false;
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: false,
},
});
});
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
});
it("starts the built-in daemon when management is re-enabled", async () => {
settingsState.settings.daemon.manageBuiltInDaemon = false;
const screen = render(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
await waitFor(() => {
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: true,
},
});
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
...daemonStatusState.data.status,
status: "running",
desktopManaged: true,
});
});
});

View File

@@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
@@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: {
if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true);
void updateSettings({ manageBuiltInDaemon: true })
.then(() => startDesktopDaemon())
.then((newStatus) => {
setStatus(newStatus);
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management.");
@@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: {
setIsUpdatingDaemonManagement(true);
const stopPromise =
daemonStatus?.status === "running"
? stopDesktopDaemon()
: Promise.resolve(daemonStatus ?? null);
void stopPromise
void updateSettings({ manageBuiltInDaemon: false })
.then(() => {
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
return stopDesktopDaemon();
}
return daemonStatus ?? null;
})
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
return;
})
.then(() => {
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error);
Alert.alert("Error", "Unable to pause built-in daemon management.");
console.error("[Settings] Failed to stop built-in daemon", error);
Alert.alert(
"Error",
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
);
})
.finally(() => {
setIsUpdatingDaemonManagement(false);

View File

@@ -11,6 +11,7 @@ import {
} from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {

View File

@@ -188,6 +188,21 @@ describe("DaemonStartService", () => {
expect(service.getLastError()).toBeNull();
});
it("recordError surfaces an external error and notifies subscribers", () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus(),
});
const notifications = vi.fn();
service.subscribe(notifications);
service.recordError("settings file unreadable");
expect(service.getLastError()).toBe("settings file unreadable");
expect(notifications).toHaveBeenCalledTimes(1);
});
it("stops notifying after a subscriber unsubscribes", async () => {
const fake = createFakeStore();
let notifications = 0;

View File

@@ -53,6 +53,10 @@ export class DaemonStartService {
return this.lastError;
}
recordError(message: string): void {
this.setLastError(message);
}
isRunning(): boolean {
return this.inFlightCount > 0;
}

View File

@@ -45,7 +45,7 @@ function getWorkspaceHostStateTitle(
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
): string {
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
return `Connecting to ${state.hostName}`;
return "Connecting";
}
if (state.connectionStatus === "offline") {
return `${state.hostName} is offline`;
@@ -87,7 +87,9 @@ function WorkspaceUnreachable({
<View style={styles.textStack}>
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
<Text style={styles.description}>
Host status: {formatConnectionStatus(state.connectionStatus)}
{state.connectionStatus === "connecting" || state.connectionStatus === "idle"
? state.hostName
: `Host status: ${formatConnectionStatus(state.connectionStatus)}`}
</Text>
{state.lastError ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>

View File

@@ -10,6 +10,10 @@ import {
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => {
deferred.resolve({});
await archive;
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(true);
});
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: "ws-feature",
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
deferred.resolve({});
await archive;
});
it("restores an optimistically hidden worktree when archive fails", async () => {

View File

@@ -8,6 +8,14 @@ import {
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
const SUCCESS_DISPLAY_MS = 1000;
@@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
}) ?? input.worktreePath;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
return {
workspace:
useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ??
null,
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
predicate: (query) =>
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
@@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
markWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
});
removeWorktreeFromCachedLists({ serverId, worktreePath });
removeWorktreeFromSessionStore({ serverId, worktreePath });
removeWorktreeFromSessionStore({
serverId,
worktreePath: snapshot.workspace?.id ?? worktreePath,
});
try {
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
clearWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
});
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}

View File

@@ -23,6 +23,7 @@ import type {
WorkspaceDescriptorPayload,
} from "@server/shared/messages";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
@@ -1114,11 +1115,15 @@ export const useSessionStore = create<SessionStore>()(
removeWorkspace: (serverId, workspaceId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || !session.workspaces.has(workspaceId)) {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: session?.workspaces,
workspaceId,
});
if (!session || !workspaceKey) {
return prev;
}
const next = new Map(session.workspaces);
next.delete(workspaceId);
next.delete(workspaceKey);
return {
...prev,
sessions: {

View File

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

View File

@@ -0,0 +1,80 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242;
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
});

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env npx tsx
/**
* Regression: executable daemon launch commands must enter the supervisor.
* The worker entry remains an implementation detail of supervisor-entrypoint.
*/
import assert from "node:assert";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const repoRoot = join(import.meta.dirname, "../../..");
const serverPackagePath = join(repoRoot, "packages/server/package.json");
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
const serverConnectionOfferE2ePath = join(
repoRoot,
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
);
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
const nixPackagePath = join(repoRoot, "nix/package.nix");
function assertNoDirectWorkerLaunch(label: string, command: string): void {
assert(
!command.includes("src/server/index.ts"),
`${label} must not launch src/server/index.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/index.js"),
`${label} must not launch dist/server/server/index.js directly: ${command}`,
);
assert(
!command.includes("src/server/daemon-worker.ts"),
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/daemon-worker.js"),
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
);
}
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
assertNoDirectWorkerLaunch(label, source);
assert(
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
`${label} must not spawn ../index.ts directly`,
);
}
console.log("=== Daemon Launch Supervision Regression ===\n");
console.log("Test 1: server package scripts launch supervisor-entrypoint");
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
scripts?: Record<string, string>;
};
const startScript = serverPackage.scripts?.start ?? "";
const devScript = serverPackage.scripts?.dev ?? "";
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
assertNoDirectWorkerLaunch("server start script", startScript);
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
assertNoDirectWorkerLaunch("server dev script", devScript);
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
console.log("✓ server package scripts enter supervisor\n");
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
assert(
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
);
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
console.log("✓ app e2e setup enters supervisor\n");
console.log("Test 3: server daemon e2e process launch enters supervisor");
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
assert(
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
"server daemon e2e process launch should use supervisor-entrypoint.ts",
);
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
console.log("✓ server daemon e2e process launch enters supervisor\n");
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
assert(
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
);
assert(
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
);
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
const nixPackage = await readFile(nixPackagePath, "utf-8");
assert(
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
);
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
console.log("=== Daemon launch supervision regression test passed ===");

View File

@@ -1,198 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
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 ExitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
if (client) {
await client.close().catch(() => undefined);
return true;
}
if (Date.now() >= deadline) return false;
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid;
} catch {
return null;
}
}
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
daemonProcess.stdout?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
daemonProcess.stderr?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () =>
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
(await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined);
}
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(
exit.code,
0,
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
);
await waitFor(
async () => (await readPidLockPid(paseoHome)) === null,
15000,
"pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Unsupervised restart regression test passed ===");

View File

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

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
settings: {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: true,
},
},
runCliJsonCommand: vi.fn(),
runCliTextCommand: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: false,
},
ipcMain: { handle: vi.fn() },
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
}));
vi.mock("electron-log/main", () => ({
default: { info: vi.fn(), error: vi.fn() },
}));
vi.mock("@getpaseo/server", () => ({
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
spawnProcess: mocks.spawnProcess,
}));
vi.mock("../settings/desktop-settings-electron.js", () => ({
getDesktopSettingsStore: () => ({
get: async () => mocks.settings,
patch: vi.fn(),
migrateLegacyRendererSettings: vi.fn(),
}),
}));
vi.mock("./runtime-paths.js", () => ({
createNodeEntrypointInvocation: vi.fn(() => ({
command: "node",
args: [],
env: {},
})),
resolveDaemonRunnerEntrypoint: vi.fn(() => ({
entryPath: "/tmp/daemon.js",
execArgv: [],
})),
runCliJsonCommand: mocks.runCliJsonCommand,
runCliTextCommand: mocks.runCliTextCommand,
}));
function desktopSettingsWithManagement(enabled: boolean) {
return {
...DEFAULT_DESKTOP_SETTINGS,
daemon: {
...DEFAULT_DESKTOP_SETTINGS.daemon,
manageBuiltInDaemon: enabled,
},
};
}
describe("daemon-manager commands", () => {
beforeEach(() => {
mocks.settings = DEFAULT_DESKTOP_SETTINGS;
mocks.runCliJsonCommand.mockReset();
mocks.runCliTextCommand.mockReset();
mocks.spawnProcess.mockReset();
});
it("refuses start and restart while built-in daemon management is disabled", async () => {
mocks.settings = desktopSettingsWithManagement(false);
const handlers = createDaemonCommandHandlers();
await expect(handlers.start_desktop_daemon()).rejects.toThrow(
"Built-in daemon management is disabled.",
);
await expect(handlers.restart_desktop_daemon()).rejects.toThrow(
"Built-in daemon management is disabled.",
);
expect(mocks.runCliJsonCommand).not.toHaveBeenCalled();
expect(mocks.spawnProcess).not.toHaveBeenCalled();
});
it("keeps stop callable while built-in daemon management is disabled", async () => {
mocks.settings = desktopSettingsWithManagement(false);
mocks.runCliJsonCommand.mockResolvedValue({
localDaemon: "stopped",
serverId: "",
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
serverId: "",
status: "stopped",
listen: null,
hostname: null,
pid: null,
home: "/tmp/paseo-home",
version: null,
desktopManaged: false,
error: null,
});
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
});

View File

@@ -38,6 +38,7 @@ import {
createDesktopSettingsCommandHandlers,
type DesktopCommandHandler,
} from "../settings/desktop-settings-commands.js";
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
@@ -285,6 +286,12 @@ function shouldRestartForVersion(current: DesktopDaemonStatus): boolean {
return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion);
}
function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
if (!settings.daemon.manageBuiltInDaemon) {
throw new Error("Built-in daemon management is disabled.");
}
}
function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error },
stdout: string,
@@ -322,6 +329,8 @@ async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
}
async function startDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
const current = await resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("initial status check before start", {
status: current.status,
@@ -457,6 +466,7 @@ export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
}
async function restartDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
await stopDesktopDaemon();
return startDaemon();
}

View File

@@ -43,6 +43,22 @@ describe("desktop-settings", () => {
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
});
it("handles concurrent first-launch reads without racing the settings write", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await Promise.all(Array.from({ length: 20 }, () => store.get()));
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
settings: DesktopSettings;
};
const files = await readdir(userDataPath);
expect(settings).toEqual(Array.from({ length: 20 }, () => DEFAULT_DESKTOP_SETTINGS));
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
expect(files).toEqual(["desktop-settings.json"]);
});
it("coerces invalid persisted values back to safe defaults", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
@@ -94,6 +110,58 @@ describe("desktop-settings", () => {
expect(files).toEqual(["desktop-settings.json"]);
});
it("does not let stale legacy renderer settings override an explicit desktop patch", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
const patched = await store.patch({
daemon: {
manageBuiltInDaemon: false,
},
});
const migrated = await store.migrateLegacyRendererSettings({
manageBuiltInDaemon: true,
releaseChannel: "beta",
});
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
migrations: { legacyRendererSettingsImported: boolean };
settings: DesktopSettings;
};
expect(patched.daemon.manageBuiltInDaemon).toBe(false);
expect(migrated.daemon.manageBuiltInDaemon).toBe(false);
expect(migrated.releaseChannel).toBe("stable");
expect(persisted.migrations.legacyRendererSettingsImported).toBe(true);
expect(persisted.settings.daemon.manageBuiltInDaemon).toBe(false);
});
it("does not rewrite existing settings while reading them", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const raw = JSON.stringify({
version: 1,
settings: {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: false,
keepRunningAfterQuit: true,
},
},
migrations: {
legacyRendererSettingsImported: false,
},
});
await writeFile(settingsFilePath(userDataPath), raw);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await store.get();
const persisted = await readFile(settingsFilePath(userDataPath), "utf8");
expect(settings.daemon.manageBuiltInDaemon).toBe(false);
expect(persisted).toBe(raw);
});
it("migrates desktop-owned values from legacy renderer settings once", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);

View File

@@ -1,4 +1,5 @@
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppReleaseChannel } from "../features/auto-updater.js";
@@ -58,10 +59,17 @@ function coerceBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error;
}
function buildDefaultDocument(): PersistedDesktopSettingsDocument {
return {
version: 1,
settings: DEFAULT_DESKTOP_SETTINGS,
settings: {
releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel,
daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon },
},
migrations: {
legacyRendererSettingsImported: false,
},
@@ -159,6 +167,10 @@ function mergeDesktopSettings(
};
}
function hasLegacyRendererOwnedPatch(patch: DesktopSettingsPatch): boolean {
return patch.releaseChannel !== undefined || patch.daemon?.manageBuiltInDaemon !== undefined;
}
function coerceDocument(input: unknown): PersistedDesktopSettingsDocument {
if (!isRecord(input)) {
return buildDefaultDocument();
@@ -187,13 +199,19 @@ export function createDesktopSettingsStore({
}): DesktopSettingsStore {
const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME);
let cachedDocument: PersistedDesktopSettingsDocument | null = null;
let persistQueue: Promise<void> = Promise.resolve();
async function persistDocument(document: PersistedDesktopSettingsDocument): Promise<void> {
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
cachedDocument = document;
const write = async () => {
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
cachedDocument = document;
};
const queued = persistQueue.then(write, write);
persistQueue = queued.catch(() => undefined);
await queued;
}
async function loadDocument(): Promise<PersistedDesktopSettingsDocument> {
@@ -201,19 +219,31 @@ export function createDesktopSettingsStore({
return cachedDocument;
}
let raw: string;
try {
await access(filePath);
} catch {
raw = await readFile(filePath, "utf8");
} catch (error) {
if (!isNodeError(error) || error.code !== "ENOENT") {
throw error;
}
const document = buildDefaultDocument();
await persistDocument(document);
return document;
}
const document = coerceDocument(JSON.parse(raw));
cachedDocument = document;
return document;
}
async function loadWritableDocument(): Promise<PersistedDesktopSettingsDocument> {
const document = await loadDocument();
await persistDocument(document);
return document;
}
async function initializeLegacyRendererMigration(): Promise<PersistedDesktopSettingsDocument> {
try {
const raw = await readFile(filePath, "utf8");
const document = coerceDocument(JSON.parse(raw));
await persistDocument(document);
return document;
return await loadDocument();
} catch {
const document = buildDefaultDocument();
await persistDocument(document);
@@ -228,17 +258,24 @@ export function createDesktopSettingsStore({
},
async patch(patch: unknown): Promise<DesktopSettings> {
const current = await loadDocument();
const next = mergeDesktopSettings(current.settings, coerceDesktopSettingsPatch(patch));
const current = await loadWritableDocument();
const coercedPatch = coerceDesktopSettingsPatch(patch);
const next = mergeDesktopSettings(current.settings, coercedPatch);
await persistDocument({
...current,
settings: next,
migrations: {
...current.migrations,
legacyRendererSettingsImported:
current.migrations.legacyRendererSettingsImported ||
hasLegacyRendererOwnedPatch(coercedPatch),
},
});
return next;
},
async migrateLegacyRendererSettings(legacySettings: unknown): Promise<DesktopSettings> {
const current = await loadDocument();
const current = await initializeLegacyRendererMigration();
if (current.migrations.legacyRendererSettingsImported) {
return current.settings;
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.66",
"version": "0.1.69",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -29,12 +29,12 @@
},
"scripts": {
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' scripts/dev-runner.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "node dist/server/server/index.js",
"start": "node dist/scripts/supervisor-entrypoint.js",
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.66",
"@getpaseo/relay": "0.1.66",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -74,6 +74,7 @@
"dotenv": "^17.2.3",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",

View File

@@ -26,7 +26,10 @@ const supervisorArgs = [
const supervisor = spawn(process.execPath, supervisorArgs, {
stdio: "inherit",
env: process.env,
env: {
...process.env,
PASEO_LOG_FORMAT: process.env.PASEO_LOG_FORMAT ?? "pretty",
},
});
function exitCodeForSignal(signal: NodeJS.Signals): number {

View File

@@ -15,6 +15,17 @@ describe("supervision parity", () => {
expect(daemonRunnerCalls + devRunnerCalls).toBe(1);
});
test("supervisor worker implementation is not server/index", () => {
const daemonRunner = readFileSync(
new URL("./supervisor-entrypoint.ts", import.meta.url),
"utf8",
);
expect(daemonRunner).toContain("daemon-worker");
expect(daemonRunner).not.toContain("server/server/index.js");
expect(daemonRunner).not.toContain("src/server/index.ts");
});
test("dev runner waits asynchronously for supervisor shutdown", () => {
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");

View File

@@ -8,9 +8,14 @@ import {
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -33,10 +38,10 @@ function parseConfig(argv: string[]): DaemonRunnerConfig {
function resolveWorkerEntry(): string {
const candidates = [
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)),
];
for (const candidate of candidates) {
@@ -49,7 +54,7 @@ function resolveWorkerEntry(): string {
}
function resolveDevWorkerEntry(): string {
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
const candidate = fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url));
if (!existsSync(candidate)) {
throw new Error(`Dev worker entry not found: ${candidate}`);
}
@@ -72,11 +77,33 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null;
}
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
@@ -85,6 +112,8 @@ async function main(): Promise<void> {
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
try {
await acquirePidLock(paseoHome, null, {
@@ -112,9 +141,7 @@ async function main(): Promise<void> {
runSupervisor({
name: "DaemonRunner",
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv,
@@ -134,7 +161,8 @@ async function main(): Promise<void> {
},
})
: undefined,
restartOnCrash: config.devMode,
restartOnCrash: true,
logFile: supervisorLogFile,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},

View File

@@ -0,0 +1,132 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
log: string;
stdout: string;
stderr: string;
}> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-log-"));
const logPath = path.join(tempDir, "daemon.log");
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(workerPath, options.workerSource);
await writeFile(
runnerPath,
`
import { runSupervisor } from ${JSON.stringify(pathToFileURL(supervisorPath).href)};
runSupervisor({
name: "TestSupervisor",
startupMessage: "starting fixture",
resolveWorkerEntry: () => ${JSON.stringify(workerPath)},
workerArgs: [],
workerEnv: process.env,
workerExecArgv: [],
restartOnCrash: ${JSON.stringify(options.restartOnCrash ?? false)},
logFile: {
path: ${JSON.stringify(logPath)},
rotate: { maxSize: "1m", maxFiles: 2 },
},
});
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const { code, signal } = await new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("supervisor fixture timed out"));
}, 10000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode, exitSignal) => {
clearTimeout(timeout);
resolve({ code: exitCode, signal: exitSignal });
});
});
const log = await readFile(logPath, "utf8");
return { code, signal, log, stdout, stderr };
}
describe("supervisor durable logging", () => {
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('{"level":30,"msg":"worker-json-stdout"}\\n');
process.stderr.write('{"level":50,"msg":"worker-json-stderr"}\\n');
process.exit(0);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"worker-json-stdout"');
expect(result.log).toContain('"worker-json-stderr"');
expect(result.stdout).toContain('"worker-json-stdout"');
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('raw stdout line\\n');
process.stderr.write('raw stderr line\\n');
process.exit(0);
`,
});
expect(result.log).toContain("raw stdout line\n");
expect(result.log).toContain("raw stderr line\n");
});
test("logs worker signal exits even when the worker cannot log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
});
});

View File

@@ -1,4 +1,15 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
interface SupervisorLogFileOptions {
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
}
type WorkerLifecycleMessage =
| {
@@ -28,6 +39,7 @@ interface SupervisorOptions {
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
logFile?: SupervisorLogFileOptions;
}
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
@@ -59,6 +71,31 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return null;
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
mkdirSync(path.dirname(options.path), { recursive: true });
return createRotatingFileStream(path.basename(options.path), {
path: path.dirname(options.path),
size: toRotatingFileStreamSize(options.rotate.maxSize),
maxFiles: options.rotate.maxFiles,
});
}
export function runSupervisor(options: SupervisorOptions): void {
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
@@ -70,11 +107,39 @@ export function runSupervisor(options: SupervisorOptions): void {
let restarting = false;
let shuttingDown = false;
let exiting = false;
const logStream = createSupervisorLogStream(options.logFile);
const writeDurableChunk = (chunk: string | Buffer): void => {
logStream?.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
writeDurableChunk(
`${JSON.stringify({
level: "info",
time: new Date().toISOString(),
pid: process.pid,
name: options.name,
msg: message,
...fields,
})}\n`,
);
};
const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`);
writeLifecycleLog(message);
};
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const exitSupervisor = (code: number): void => {
if (exiting) {
return;
@@ -85,6 +150,7 @@ export function runSupervisor(options: SupervisorOptions): void {
const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`);
})
.then(closeLogStream)
.finally(() => {
process.exit(code);
});
@@ -103,19 +169,30 @@ export function runSupervisor(options: SupervisorOptions): void {
}
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
writeLifecycleLog("Spawning worker", { workerEntry });
if (spawnSpec) {
child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["inherit", "inherit", "inherit", "ipc"],
stdio: ["inherit", "pipe", "pipe", "ipc"],
env: spawnSpec.env ?? workerEnv,
});
} else {
child = fork(workerEntry, workerArgs, {
stdio: "inherit",
stdio: ["inherit", "pipe", "pipe", "ipc"],
env: workerEnv,
execArgv: workerExecArgv,
});
}
child.stdout?.on("data", (chunk: Buffer) => {
process.stdout.write(chunk);
writeDurableChunk(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
writeDurableChunk(chunk);
});
child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
@@ -123,6 +200,7 @@ export function runSupervisor(options: SupervisorOptions): void {
}
if (lifecycleMessage.type === "paseo:ready") {
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
(error) => {
const message = error instanceof Error ? error.message : String(error);
@@ -133,15 +211,21 @@ export function runSupervisor(options: SupervisorOptions): void {
}
if (lifecycleMessage.type === "paseo:shutdown") {
writeLifecycleLog("Worker requested shutdown");
requestShutdown("Shutdown requested by worker");
return;
}
writeLifecycleLog(
"Worker requested restart",
lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {},
);
requestRestart("Restart requested by worker");
});
child.on("exit", (code, signal) => {
child.on("close", (code, signal) => {
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
@@ -151,17 +235,21 @@ export function runSupervisor(options: SupervisorOptions): void {
const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL"));
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) {
restarting = false;
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
log(
crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`,
);
spawnWorker();
return;
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 0);
exitSupervisor(typeof code === "number" ? code : 1);
});
};
@@ -170,6 +258,7 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
restarting = true;
writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`);
child.kill("SIGTERM");
};
@@ -180,6 +269,7 @@ export function runSupervisor(options: SupervisorOptions): void {
}
shuttingDown = true;
restarting = false;
writeLifecycleLog("Supervisor shutdown requested", { reason });
log(`${reason}. Stopping worker...`);
if (!child) {
exitSupervisor(0);
@@ -196,5 +286,6 @@ export function runSupervisor(options: SupervisorOptions): void {
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
writeLifecycleLog(options.startupMessage);
spawnWorker();
}

View File

@@ -9,6 +9,7 @@ import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import type {
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentProvider,
@@ -705,6 +706,48 @@ test("createAgent passes daemon launch env through the provider launch context",
});
});
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastCreateOptions: AgentCreateSessionOptions | undefined;
override async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
this.lastCreateOptions = options;
return new TestAgentSession(config);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ persistSession: false },
);
expect(client.lastCreateOptions).toEqual({ persistSession: false });
rmSync(workdir, { recursive: true, force: true });
});
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");

View File

@@ -12,6 +12,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentSlashCommand,
@@ -727,6 +728,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
persistSession?: boolean;
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
@@ -749,13 +751,22 @@ export class AgentManager {
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
});
const session = await client.createSession(normalizedConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(normalizedConfig, launchContext, createOptions);
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
labels: options?.labels,
workspaceId: options?.workspaceId,
});
}
private buildCreateSessionOptions(options?: {
persistSession?: boolean;
}): AgentCreateSessionOptions | undefined {
return options?.persistSession === undefined
? undefined
: { persistSession: options.persistSession };
}
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
async resumeAgentFromPersistence(

View File

@@ -0,0 +1,226 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, readdirSync, rmSync, statSync, realpathSync } from "fs";
import { homedir, tmpdir } from "os";
import path from "path";
import pino from "pino";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
import { OpenCodeServerManager } from "./providers/opencode-agent.js";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function collectFilesRecursively(root: string, filter: (name: string) => boolean): Set<string> {
const results = new Set<string>();
try {
statSync(root);
} catch {
return results;
}
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && filter(entry.name)) {
results.add(full);
}
}
}
return results;
}
function collectCodexRolloutFiles(): Set<string> {
const codexHome = process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
return collectFilesRecursively(path.join(codexHome, "sessions"), (name) =>
name.startsWith("rollout-"),
);
}
function encodeClaudeProjectDir(cwd: string): string {
return cwd.replaceAll("/", "-");
}
function collectClaudeProjectFiles(cwd: string): Set<string> {
const dir = path.join(homedir(), ".claude", "projects", encodeClaudeProjectDir(cwd));
return collectFilesRecursively(dir, (name) => name.endsWith(".jsonl"));
}
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
}
describe("agent metadata generation (real agents)", () => {
const logger = pino({ level: "silent" });
let cwd: string;
let paseoHome: string;
let manager: AgentManager;
let storage: AgentStorage;
let codexAvailable = false;
let claudeAvailable = false;
let opencodeAvailable = false;
beforeAll(async () => {
[codexAvailable, claudeAvailable, opencodeAvailable] = await Promise.all([
isProviderAvailable("codex"),
isProviderAvailable("claude"),
isProviderAvailable("opencode"),
]);
});
beforeEach(() => {
cwd = tmpCwd("metadata-cwd-");
paseoHome = tmpCwd("metadata-paseo-home-");
storage = new AgentStorage(path.join(paseoHome, "agents"), logger);
manager = new AgentManager({
clients: createAllClients(logger),
registry: storage,
logger,
});
});
afterEach(async () => {
await shutdownProviders(logger);
rmSync(cwd, { recursive: true, force: true });
rmSync(paseoHome, { recursive: true, force: true });
}, 60000);
test("generates a title using a real Codex agent without persisting a rollout", async (ctx) => {
if (!codexAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: cwd,
title: "Main Agent",
},
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
);
const rolloutsBefore = collectCodexRolloutFiles();
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
const rolloutsAfter = collectCodexRolloutFiles();
const newRollouts = [...rolloutsAfter].filter((file) => !rolloutsBefore.has(file));
expect(newRollouts).toEqual([]);
await manager.closeAgent(agent.id);
}, 180000);
test("generates a title using a real Claude agent without persisting a session", async (ctx) => {
if (!claudeAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "claude",
model: "haiku",
thinkingOptionId: "on",
cwd: cwd,
title: "Main Claude Agent",
},
"5e1b5619-f633-5fea-9495-d04bf990f27e",
);
const sessionsBefore = collectClaudeProjectFiles(cwd);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'Claude Metadata Title'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Claude Metadata Title");
const sessionsAfter = collectClaudeProjectFiles(cwd);
const newSessions = [...sessionsAfter].filter((file) => !sessionsBefore.has(file));
expect(newSessions).toEqual([]);
await manager.closeAgent(agent.id);
}, 180000);
test("generates a title using a real OpenCode agent and deletes the ephemeral session", async (ctx) => {
if (!opencodeAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "build",
cwd: cwd,
title: "Main OpenCode Agent",
},
"6e2c6720-e744-6fdb-a5a6-e15cf0a1f380",
);
const acquisition = await OpenCodeServerManager.getInstance(logger).acquire({ force: false });
const inspectClient = createOpencodeClient({
baseUrl: acquisition.server.url,
directory: cwd,
});
try {
const sessionsBeforeRes = await inspectClient.session.list({ directory: cwd });
const sessionIdsBefore = new Set((sessionsBeforeRes.data ?? []).map((session) => session.id));
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'OpenCode Metadata Title'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("OpenCode Metadata Title");
const sessionsAfterRes = await inspectClient.session.list({ directory: cwd });
const newSessions = (sessionsAfterRes.data ?? []).filter(
(session) => !sessionIdsBefore.has(session.id),
);
expect(newSessions).toEqual([]);
} finally {
acquisition.release();
await manager.closeAgent(agent.id);
}
}, 180000);
});

View File

@@ -1,184 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import pino from "pino";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import {
createWorktree as createWorktreePrimitive,
validateBranchSlug,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "../../utils/worktree.js";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const shouldRun = !process.env.CI && !!process.env.OPENAI_API_KEY;
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
}
function initGitRepo(repoDir: string): void {
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'paseo-test@example.com'", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git config user.name 'Paseo Test'", {
cwd: repoDir,
stdio: "pipe",
});
writeFileSync(path.join(repoDir, "README.md"), "init\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd: repoDir,
stdio: "pipe",
});
}
(shouldRun ? describe : describe.skip)("agent metadata generation (real agents)", () => {
const logger = pino({ level: "silent" });
let repoDir: string;
let paseoHome: string;
let storagePath: string;
let manager: AgentManager;
let storage: AgentStorage;
let codexSessionDir: string;
let previousCodexSessionDir: string | undefined;
beforeEach(() => {
repoDir = tmpCwd("metadata-repo-");
initGitRepo(repoDir);
paseoHome = tmpCwd("metadata-paseo-home-");
storagePath = path.join(paseoHome, "agents");
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({
clients: createAllClients(logger),
registry: storage,
logger,
});
codexSessionDir = tmpCwd("codex-sessions-");
previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
process.env.CODEX_SESSION_DIR = codexSessionDir;
});
afterEach(async () => {
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
await shutdownProviders(logger);
rmSync(repoDir, { recursive: true, force: true });
rmSync(paseoHome, { recursive: true, force: true });
rmSync(codexSessionDir, { recursive: true, force: true });
}, 60000);
test("generates a title using a real Codex agent", async () => {
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: repoDir,
title: "Main Agent",
},
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: repoDir,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
await manager.closeAgent(agent.id);
}, 180000);
test("renames the worktree branch using a real Codex agent", async () => {
const worktreeSlug = "metadata-worktree";
const worktree = await createLegacyWorktreeForTest({
branchName: worktreeSlug,
cwd: repoDir,
baseBranch: "main",
worktreeSlug,
paseoHome,
});
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: worktree.worktreePath,
title: "Worktree Agent",
},
"32bb765d-f637-44a2-9820-f2efd5261418",
);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: worktree.worktreePath,
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
explicitTitle: "Explicit Title",
paseoHome,
logger,
});
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
const validation = validateBranchSlug(currentBranch);
expect(validation.valid).toBe(true);
expect(currentBranch).toBe("feat/metadata-worktree");
await manager.closeAgent(agent.id);
}, 180000);
});

View File

@@ -115,6 +115,7 @@ export async function generateAndApplyAgentMetadata(
schemaName: "AgentMetadata",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
agentConfigOverrides: {
title: "Agent metadata generator",
internal: true,

View File

@@ -86,7 +86,10 @@ describe("agent metadata generator auto-title", () => {
});
expect(generateStructured).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }),
expect.objectContaining({
cwd: "/tmp/repo/metadata-worktree",
persistSession: false,
}),
);
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
});

View File

@@ -142,7 +142,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
}
it("uses the first available provider in the waterfall", async () => {
const calls: Array<{ provider: string; model?: string }> = [];
const calls: Array<{ provider: string; model?: string; persistSession?: boolean }> = [];
const manager = createManager([
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: true, error: null },
@@ -158,17 +158,19 @@ describe("generateStructuredAgentResponseWithFallback", () => {
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.4-mini" },
],
persistSession: false,
runner: async (options) => {
calls.push({
provider: options.agentConfig.provider,
model: options.agentConfig.model ?? undefined,
persistSession: options.persistSession,
});
return { summary: "ok" };
},
});
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]);
});
it("skips unavailable providers and uses the next available one", async () => {

View File

@@ -70,6 +70,7 @@ export interface StructuredAgentGenerationOptions<T> {
manager: AgentManager;
agentConfig: AgentSessionConfig;
agentId?: string;
persistSession?: boolean;
prompt: string;
schema: z.ZodType<T> | JsonSchema;
maxRetries?: number;
@@ -86,6 +87,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
AgentSessionConfig,
"provider" | "cwd" | "model" | "thinkingOptionId"
>;
persistSession?: boolean;
maxRetries?: number;
schemaName?: string;
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
@@ -339,12 +341,15 @@ export async function getStructuredAgentResponse<T>(
export async function generateStructuredAgentResponse<T>(
options: StructuredAgentGenerationOptions<T>,
): Promise<T> {
const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options;
const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } =
options;
const modeId =
agentConfig.modeId ??
getAgentProviderDefinition(agentConfig.provider).defaultModeId ??
undefined;
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId);
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, {
persistSession,
});
try {
const caller: AgentCaller = async (nextPrompt) => {
const result = await manager.runAgent(agent.id, nextPrompt);
@@ -388,6 +393,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
schema,
providers,
agentConfigOverrides,
persistSession,
maxRetries,
schemaName,
runner,
@@ -423,6 +429,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
schema,
maxRetries,
schemaName,
persistSession,
agentConfig: {
...agentConfigOverrides,
provider: candidate.provider,

View File

@@ -472,6 +472,14 @@ export interface AgentLaunchContext {
env?: Record<string, string>;
}
export interface AgentCreateSessionOptions {
/**
* Whether the provider should leave a durable native session behind.
* Defaults to true. Providers that cannot honor false should no-op.
*/
persistSession?: boolean;
}
/**
* Returned by respondToPermission when the permission resolution requires
* a follow-up turn (e.g. Codex plan approval → implementation).
@@ -523,6 +531,7 @@ export interface AgentClient {
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession>;
resumeSession(
handle: AgentPersistenceHandle,

View File

@@ -21,7 +21,6 @@ import {
type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import { createWorktreeCoreDeps } from "../worktree-core.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
@@ -307,9 +306,8 @@ function createPaseoWorktreeForMcpTest(options: {
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktreeService(input, {
...coreDeps,
github,
...(serviceOptions?.resolveDefaultBranch
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
: {}),
@@ -685,7 +683,7 @@ describe("create_agent MCP tool", () => {
}
});
it("auto-names a create_agent branch-off worktree from the initial prompt without metadata branch rename", async () => {
it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
@@ -739,9 +737,11 @@ describe("create_agent MCP tool", () => {
});
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("fix-workspace-creation-naming");
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim();
expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);

View File

@@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import { captureTerminalLines } from "../../terminal/terminal-capture.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,

View File

@@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
import {
ClaudeAgentClient,
convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionUpdatedInput,
} from "./claude-agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
interface TestClaudeSession {
@@ -364,6 +368,138 @@ describe("ClaudeAgentClient.listModels", () => {
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => {
expect(
normalizeClaudeAskUserQuestionUpdatedInput(
{
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { Provider: "Claude" },
},
undefined,
),
).toEqual({
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Claude" },
});
});
test("uses fallback request questions when response only includes answers", () => {
expect(
normalizeClaudeAskUserQuestionUpdatedInput(
{
answers: { Provider: "Codex" },
},
{
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
},
),
).toEqual({
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Codex" },
});
});
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const request = {
id: "permission-question-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: {
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
},
};
const resultPromise = new Promise<unknown>((resolve, reject) => {
(
session as unknown as {
pendingPermissions: Map<
string,
{
request: typeof request;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
>;
}
).pendingPermissions.set(request.id, {
request,
resolve,
reject,
});
});
try {
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: {
answers: { Provider: "Claude" },
},
});
await expect(resultPromise).resolves.toEqual({
behavior: "allow",
updatedInput: {
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Claude" },
},
updatedPermissions: undefined,
});
} finally {
await session.close();
}
});
});
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
@@ -439,6 +575,68 @@ describe("ClaudeAgentSession context window usage", () => {
});
}
test("passes persistSession through to the Claude SDK query options", async () => {
const createResultTurn = (sessionId: string) => [
{
type: "system",
subtype: "init",
session_id: sessionId,
permissionMode: "default",
},
{
type: "result",
subtype: "success",
duration_ms: 10,
duration_api_ms: 8,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0,
usage: {},
permission_denials: [],
uuid: `${sessionId}-result`,
session_id: sessionId,
},
];
const nonPersistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-1")]);
const nonPersistedClient = new ClaudeAgentClient({
logger,
queryFactory: nonPersistedQueryFactory,
});
const nonPersistedSession = await nonPersistedClient.createSession(
{
provider: "claude",
cwd: process.cwd(),
},
undefined,
{ persistSession: false },
);
await nonPersistedSession.run("turn");
await nonPersistedSession.close();
expect(nonPersistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(false);
const persistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-2")]);
const persistedClient = new ClaudeAgentClient({
logger,
queryFactory: persistedQueryFactory,
});
const persistedSession = await persistedClient.createSession(
{
provider: "claude",
cwd: process.cwd(),
},
undefined,
{ persistSession: true },
);
await persistedSession.run("turn");
await persistedSession.close();
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
});
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
const session = await createSessionForTest();

View File

@@ -49,6 +49,7 @@ import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMetadata,
AgentMode,
@@ -86,6 +87,61 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = { ...fallback, ...base };
const questions =
(Array.isArray(base.questions) ? base.questions : null) ??
(Array.isArray(fallback.questions) ? fallback.questions : null);
const answers = isMetadata(base.answers) ? base.answers : null;
if (!questions || !answers) {
return merged;
}
const normalizedAnswers: Record<string, string> = {};
for (const item of questions) {
const question = isMetadata(item) ? item : null;
if (!question) {
continue;
}
const questionText = readNonEmptyString(question.question);
if (!questionText) {
continue;
}
const header = readNonEmptyString(question.header);
const answer =
readNonEmptyString(answers[questionText]) ??
(header ? readNonEmptyString(answers[header]) : null);
if (answer) {
normalizedAnswers[questionText] = answer;
}
}
if (Object.keys(normalizedAnswers).length === 0) {
return merged;
}
return {
...merged,
answers: normalizedAnswers,
};
}
type TurnState = "idle" | "foreground" | "autonomous";
interface EventIdentifiers {
@@ -177,6 +233,7 @@ interface ClaudeAgentSessionOptions {
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
persistSession?: boolean;
logger: Logger;
queryFactory?: typeof query;
}
@@ -316,6 +373,7 @@ interface ClaudeOptionsLogSummary {
hasSpawnOverride: boolean;
hasStderrHandler: boolean;
pathToClaudeCodeExecutable: string | null;
persistSession: boolean | null;
}
const MAX_RECENT_STDERR_CHARS = 4000;
@@ -363,6 +421,7 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS
typeof options.pathToClaudeCodeExecutable === "string"
? options.pathToClaudeCodeExecutable
: null,
persistSession: typeof options.persistSession === "boolean" ? options.persistSession : null,
};
}
@@ -1151,12 +1210,14 @@ export class ClaudeAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
launchEnv: launchContext?.env,
persistSession: options?.persistSession,
logger: this.logger,
queryFactory: this.queryFactory,
});
@@ -1441,6 +1502,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly launchEnv?: Record<string, string>;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly persistSession?: boolean;
private readonly logger: Logger;
private readonly queryFactory: typeof query;
private query: Query | null = null;
@@ -1488,6 +1550,7 @@ class ClaudeAgentSession implements AgentSession {
this.launchEnv = options.launchEnv;
this.defaults = options.defaults;
this.runtimeSettings = options.runtimeSettings;
this.persistSession = options.persistSession;
this.logger = options.logger;
this.queryFactory = options.queryFactory ?? query;
const handle = options.handle;
@@ -1764,9 +1827,16 @@ class ClaudeAgentSession implements AgentSession {
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
const result: PermissionResult = {
behavior: "allow",
updatedInput: response.updatedInput ?? pending.request.input ?? {},
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
@@ -2216,6 +2286,7 @@ class ClaudeAgentSession implements AgentSession {
...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}),
...extraClaudeOptions,
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
env: sdkEnv,
};

View File

@@ -83,6 +83,66 @@ function asInternals(session: CodexTestSession): CodexSessionTestAccess {
}
describe("Codex app-server provider", () => {
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const fakeClient: CodexClientLike = {
async request(method: string, params?: unknown) {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "ephemeral-thread" } };
}
return null;
},
};
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
{},
true,
);
(session as unknown as { client: CodexClientLike }).client = fakeClient;
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
expect(startCall?.params).toMatchObject({ ephemeral: true });
});
test("omits ephemeral from thread/start by default", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const fakeClient: CodexClientLike = {
async request(method: string, params?: unknown) {
requests.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "persistent-thread" } };
}
return null;
},
};
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
);
(session as unknown as { client: CodexClientLike }).client = fakeClient;
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
expect((startCall!.params as Record<string, unknown>).ephemeral).toBeUndefined();
});
test("disposes an unresponsive app-server child with SIGKILL", async () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcessWithoutNullStreams;

View File

@@ -2,6 +2,7 @@ import type {
AgentPermissionAction,
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
@@ -2617,6 +2618,7 @@ class CodexAppServerAgentSession implements AgentSession {
logger: Logger,
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false,
) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
if (config.modeId === undefined) {
@@ -3480,6 +3482,7 @@ class CodexAppServerAgentSession implements AgentSession {
? { developerInstructions: this.config.systemPrompt.trim() }
: {}),
...(innerConfig ? { config: innerConfig } : {}),
...(this.ephemeral ? { ephemeral: true } : {}),
})) as CodexThreadStartResponse;
const threadId = response?.thread?.id;
if (!threadId) {
@@ -4460,6 +4463,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const session = new CodexAppServerAgentSession(
@@ -4468,6 +4472,7 @@ export class CodexAppServerAgentClient implements AgentClient {
this.logger,
() => this.spawnAppServer(launchContext?.env),
this.deps,
options?.persistSession === false,
);
await session.connect();
return session;

View File

@@ -566,6 +566,54 @@ describe("OpenCode adapter context-window normalization", () => {
});
describe("OpenCode adapter startTurn error handling", () => {
test("deletes provider session on close when persistence is disabled", async () => {
const fakeClient = {
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockResolvedValue({ error: null }),
delete: vi.fn().mockResolvedValue({ error: null }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
new Map(),
undefined,
false,
);
await session.close();
expect(fakeClient.session.delete).toHaveBeenCalledWith({
sessionID: "ses_unit_test",
directory: "/tmp/test",
});
});
test("does not delete provider session on close by default", async () => {
const fakeClient = {
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockResolvedValue({ error: null }),
delete: vi.fn().mockResolvedValue({ error: null }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
);
await session.close();
expect(fakeClient.session.delete).not.toHaveBeenCalled();
});
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Async iterable that never yields and never resolves. The IIFE in
// startTurn synchronously hits the promptAsync throw and finishes the

View File

@@ -16,6 +16,7 @@ import { z } from "zod";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -1039,6 +1040,7 @@ export class OpenCodeAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.serverManager.acquire({ force: false });
@@ -1073,6 +1075,7 @@ export class OpenCodeAgentClient implements AgentClient {
this.logger,
new Map(this.modelContextWindows),
acquisition.release,
options?.persistSession,
);
} catch (error) {
acquisition.release();
@@ -2218,6 +2221,8 @@ class OpenCodeAgentSession implements AgentSession {
private pendingChildToolPartsBySessionId = new Map<string, OpenCodeToolPartEventPart[]>();
private selectedModelContextWindowMaxTokens: number | undefined;
private releaseServer: (() => void) | null;
private readonly persistSession: boolean;
private deletedFromProvider = false;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
@@ -2225,6 +2230,7 @@ class OpenCodeAgentSession implements AgentSession {
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
persistSession = true,
) {
this.config = config;
this.client = client;
@@ -2233,6 +2239,7 @@ class OpenCodeAgentSession implements AgentSession {
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
this.persistSession = persistSession;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
@@ -2831,6 +2838,7 @@ class OpenCodeAgentSession implements AgentSession {
directory: this.config.cwd,
logger: this.logger,
});
await this.deleteProviderSessionIfEphemeral();
this.subscribers.clear();
this.activeForegroundTurnId = null;
} finally {
@@ -2839,6 +2847,27 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private async deleteProviderSessionIfEphemeral(): Promise<void> {
if (this.persistSession || this.deletedFromProvider) {
return;
}
this.deletedFromProvider = true;
try {
const response = await this.client.session.delete({
sessionID: this.sessionId,
directory: this.config.cwd,
});
if (response.error) {
throw new Error(`OpenCode session.delete failed: ${JSON.stringify(response.error)}`);
}
} catch (error) {
this.logger.debug(
{ err: error, sessionId: this.sessionId },
"Failed to delete non-persistent OpenCode session",
);
}
}
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {

View File

@@ -90,7 +90,6 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js";
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
@@ -127,6 +126,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
@@ -202,6 +202,7 @@ export interface PaseoDaemonConfig {
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
}
@@ -575,9 +576,8 @@ export async function createPaseoDaemon(
{
paseoHome: config.paseoHome,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
return createRegisteredPaseoWorktree(workflowInput, {
...coreDeps,
github,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
@@ -842,16 +842,6 @@ export async function createPaseoDaemon(
github,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
process.send({
type: "paseo:ready",
listen:
boundListenTarget.type === "tcp"
? `${boundListenTarget.host}:${boundListenTarget.port}`
: boundListenTarget.path,
});
}
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,

View File

@@ -3,7 +3,12 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import { loadPersistedConfig } from "./persisted-config.js";
import {
loadPersistedConfig,
LogFormatSchema,
LogLevelSchema,
type PersistedConfig,
} from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
@@ -35,6 +40,14 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
return undefined;
}
function normalizeLogEnv(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
return value.trim().toLowerCase();
}
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
@@ -43,6 +56,24 @@ export type CliConfigOverrides = Partial<{
hostnames: HostnamesConfig;
}>;
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
if (!envLogLevel.success && !envLogFormat.success) {
return persisted.log;
}
return {
...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
};
}
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
@@ -271,5 +302,6 @@ export function loadConfig(
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
};
}

View File

@@ -33,7 +33,7 @@ function createLegacyWorktreeForTest(
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,

View File

@@ -6,7 +6,6 @@ import os from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -202,8 +201,9 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-"));
const port = await getAvailablePort();
const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url));
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx");
const serverRoot = path.resolve(import.meta.dirname, "../../..");
const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts");
const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx");
const env = {
...process.env,
@@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
};
const stdoutLines: string[] = [];
const proc = spawn(tsxBin, [indexPath, "--no-relay"], {
const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -2,14 +2,16 @@ import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
type SupervisorLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:ready";
listen: string;
}
| {
type: "paseo:restart";
reason?: string;
@@ -24,9 +26,8 @@ interface BootstrapResult {
function bootstrapFromEnvironment(): BootstrapResult {
try {
const paseoHome = resolvePaseoHome();
const persistedConfig = loadPersistedConfig(paseoHome);
const logger = createRootLogger(persistedConfig, { paseoHome });
const config = loadConfig(paseoHome);
const logger = createRootLogger({ log: config.log }, { paseoHome, file: false });
return { paseoHome, logger, config };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -48,12 +49,10 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
}
async function main() {
const { paseoHome, logger, config } = bootstrapFromEnvironment();
const { logger, config } = bootstrapFromEnvironment();
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
let pidLockAcquired = false;
applyCliFlagOverrides(config);
@@ -89,10 +88,6 @@ async function main() {
return 1;
}
await daemon.stop();
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
clearTimeout(forceExit);
logger.info("Server closed");
return options?.successExitCode ?? 0;
@@ -151,11 +146,6 @@ async function main() {
};
try {
if (!supervised) {
await acquirePidLock(paseoHome, null);
pidLockAcquired = true;
}
daemon = await createPaseoDaemon(
{
...config,
@@ -164,40 +154,22 @@ async function main() {
logger,
);
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon bootstrap failed");
throw err;
}
try {
await daemon.start();
if (!supervised) {
const listenTarget = daemon.getListenTarget();
const listen =
listenTarget?.type === "tcp"
? `${listenTarget.host}:${listenTarget.port}`
: listenTarget?.path;
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
}
await updatePidLock(paseoHome, { listen });
const listenTarget = daemon.getListenTarget();
const listen =
listenTarget?.type === "tcp"
? `${listenTarget.host}:${listenTarget.port}`
: listenTarget?.path;
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
}
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon failed to start listening");
throw err;
}

View File

@@ -1,47 +1,82 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveLogConfig } from "./logger.js";
import { loadConfig } from "./config.js";
import type { PersistedConfig } from "./persisted-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
const loggerModuleUrl = new URL("./logger.ts", import.meta.url).href;
async function runLoggerFixture(source: string): Promise<{ stdout: string; stderr: string }> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-logger-fixture-"));
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(
runnerPath,
`
import { createRootLogger } from ${JSON.stringify(loggerModuleUrl)};
${source}
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(code, stderr).toBe(0);
return { stdout, stderr };
}
describe("resolveLogConfig", () => {
const originalEnv = process.env;
const paseoHome = "/tmp/paseo-logger-tests";
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.PASEO_LOG;
delete process.env.PASEO_LOG_FORMAT;
delete process.env.PASEO_LOG_CONSOLE_LEVEL;
delete process.env.PASEO_LOG_FILE_LEVEL;
delete process.env.PASEO_LOG_FILE_PATH;
delete process.env.PASEO_LOG_FILE_ROTATE_SIZE;
delete process.env.PASEO_LOG_FILE_ROTATE_COUNT;
});
afterEach(() => {
process.env = originalEnv;
});
it("returns dual-sink defaults when no config or env vars", () => {
it("defaults to stdout JSON without file logging", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "debug",
level: "info",
console: {
level: "info",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
format: "json",
},
});
});
it("uses config.json destination-specific values over defaults", () => {
it("keeps legacy level and format as stdout configuration", () => {
const result = resolveLogConfig({ level: "warn", format: "pretty" }, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "pretty",
},
});
});
it("enables file output only when log.file is present", () => {
const config: PersistedConfig = {
log: {
console: {
@@ -50,7 +85,7 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: "/tmp/custom.log",
path: "logs/programmatic.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
@@ -58,9 +93,8 @@ describe("resolveLogConfig", () => {
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
expect(resolveLogConfig(config, { paseoHome })).toEqual({
level: "debug",
console: {
level: "warn",
@@ -68,166 +102,98 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
},
path: path.join(paseoHome, "logs", "programmatic.log"),
},
});
});
it("uses env vars over config.json values", () => {
process.env.PASEO_LOG_CONSOLE_LEVEL = "error";
process.env.PASEO_LOG_FILE_LEVEL = "fatal";
process.env.PASEO_LOG_FORMAT = "json";
process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log";
process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m";
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4";
const config: PersistedConfig = {
log: {
console: {
level: "info",
format: "pretty",
},
file: {
level: "trace",
path: "/tmp/will-be-overridden.log",
rotate: {
maxSize: "30m",
maxFiles: 8,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "fatal",
path: path.resolve(paseoHome, "logs/daemon-custom.log"),
rotate: {
maxSize: "15m",
maxFiles: 4,
},
},
});
});
it("keeps backwards compatibility for legacy log.level and log.format", () => {
const config: PersistedConfig = {
log: {
level: "warn",
format: "json",
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "json",
},
file: {
level: "warn",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("keeps backwards compatibility for legacy env vars", () => {
process.env.PASEO_LOG = "error";
process.env.PASEO_LOG_FORMAT = "json";
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "error",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("supports partial destination config and retains defaults", () => {
const config: PersistedConfig = {
log: {
console: {
level: "warn",
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("ignores invalid rotate count env var and falls back to config value", () => {
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0";
const config: PersistedConfig = {
log: {
file: {
rotate: {
maxFiles: 7,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result.file.rotate.maxFiles).toBe(7);
});
it("supports all log levels for destination-specific env vars", () => {
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
];
for (const level of levels) {
process.env.PASEO_LOG_CONSOLE_LEVEL = level;
process.env.PASEO_LOG_FILE_LEVEL = level;
const result = resolveLogConfig(undefined, { paseoHome });
expect(result.console.level).toBe(level);
expect(result.file.level).toBe(level);
expect(result.level).toBe(level);
}
});
});
describe("loadConfig logger config", () => {
it("applies log format env at the config boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-"));
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ version: 1, log: { format: "json" } }),
);
const config = loadConfig(paseoHome, {
env: { PASEO_LOG_FORMAT: "pretty" },
});
expect(config.log?.format).toBe("pretty");
expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty");
});
});
describe("createRootLogger", () => {
it("writes JSON to stdout by default and does not initialize file logging", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
const missingLogDir = path.join(paseoHome, "logs");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
logger.info({ proof: "stdout-default" }, "default logger");
logger.flush();
`);
expect(stdout).toContain('"proof":"stdout-default"');
expect(stdout).toContain('"msg":"default logger"');
expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false);
expect(existsSync(missingLogDir)).toBe(false);
});
it("writes to an explicit file target without creating rotation files", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-file-"));
const logPath = path.join(paseoHome, "logs", "programmatic.log");
await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)} },
);
logger.info({ proof: "file-explicit" }, "explicit file logger");
logger.flush();
`);
const logText = await readFile(logPath, "utf8");
const files = await readdir(path.dirname(logPath));
expect(logText).toContain('"proof":"file-explicit"');
expect(logText).toContain('"msg":"explicit file logger"');
expect(files).toEqual(["programmatic.log"]);
});
it("can disable file output for supervised workers", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-"));
const logPath = path.join(paseoHome, "daemon.log");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)}, file: false },
);
logger.info({ proof: "stdout-only" }, "worker logger");
logger.flush();
`);
expect(stdout).toContain('"proof":"stdout-only"');
expect(stdout).toContain('"msg":"worker logger"');
expect(existsSync(logPath)).toBe(false);
});
it("keeps pretty output available as a format choice", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-"));
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger({ level: "info", format: "pretty" }, {
paseoHome: ${JSON.stringify(paseoHome)},
});
logger.info("pretty logger");
logger.flush();
`);
expect(stdout).toContain("pretty logger");
});
});

View File

@@ -1,8 +1,7 @@
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
import { mkdirSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import pretty from "pino-pretty";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import type { PersistedConfig } from "./persisted-config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -15,13 +14,9 @@ export interface ResolvedLogConfig {
level: LogLevel;
format: LogFormat;
};
file: {
file?: {
level: LogLevel;
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
};
}
@@ -34,11 +29,9 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
interface ResolveLogConfigOptions {
paseoHome?: string;
env?: NodeJS.ProcessEnv;
file?: boolean;
}
const LOG_LEVELS: Set<LogLevel> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
const LOG_FORMATS: Set<LogFormat> = new Set(["pretty", "json"]);
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
@@ -49,10 +42,8 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
};
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [
"authorization",
@@ -69,33 +60,6 @@ const REDACT_PATHS = [
"req.headers.Sec-WebSocket-Protocol",
];
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
return undefined;
}
return value as LogLevel;
}
function parseLogFormat(value: string | undefined): LogFormat | undefined {
if (!value || !LOG_FORMATS.has(value as LogFormat)) {
return undefined;
}
return value as LogFormat;
}
function parsePositiveInteger(value: string | undefined): number | undefined {
if (!value || value.trim().length === 0) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string {
const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (!configuredPath) {
@@ -125,7 +89,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined
if (options?.paseoHome) {
return options.paseoHome;
}
return resolvePaseoHome(options?.env ?? process.env);
return resolvePaseoHome();
}
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
@@ -150,126 +114,50 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig
return config as PersistedConfig;
}
function rotateOnRestart(filePath: string, maxFiles: number): void {
if (!existsSync(filePath)) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
try {
renameSync(filePath, path.join(dir, `${ts}-00-${base}`));
} catch {
return;
}
// Clean up old rotated logs beyond maxFiles.
// Both our restart-rotated files (YYYYMMDD-HHMM-00-daemon.log) and
// rotating-file-stream's size-rotated files (YYYYMMDD-HHMM-NN-daemon.log)
// end with -${base} and sort chronologically by name.
const rotatedFiles = readdirSync(dir)
.filter((f) => f.endsWith(`-${base}`) && f !== base)
.sort()
.toReversed();
for (const file of rotatedFiles.slice(maxFiles)) {
try {
unlinkSync(path.join(dir, file));
} catch {}
}
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
interface LogLevelResolution {
consoleLevel: LogLevel;
fileLevel: LogLevel;
fileLevel?: LogLevel;
consoleFormat: LogFormat;
}
function resolveLogLevelsAndFormat(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): LogLevelResolution {
const envGlobalLevel = parseLogLevel(env.PASEO_LOG);
const persistedGlobalLevel = persistedLog?.level;
const consoleLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ??
envGlobalLevel ??
persistedLog?.console?.level ??
persistedGlobalLevel ??
DEFAULT_CONSOLE_LEVEL;
const fileLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
envGlobalLevel ??
persistedLog?.file?.level ??
persistedGlobalLevel ??
DEFAULT_FILE_LEVEL;
persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL;
const fileLevel = persistedLog?.file
? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL)
: undefined;
const consoleFormat: LogFormat =
parseLogFormat(env.PASEO_LOG_FORMAT) ??
persistedLog?.console?.format ??
persistedLog?.format ??
DEFAULT_CONSOLE_FORMAT;
persistedLog?.console?.format ?? persistedLog?.format ?? DEFAULT_CONSOLE_FORMAT;
return { consoleLevel, fileLevel, consoleFormat };
}
interface RotateResolution {
maxSize: string;
maxFiles: number;
}
function resolveRotateConfig(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): RotateResolution {
return {
maxSize:
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
persistedLog?.file?.rotate?.maxSize ||
DEFAULT_FILE_ROTATE_SIZE,
maxFiles:
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
persistedLog?.file?.rotate?.maxFiles ??
DEFAULT_FILE_ROTATE_MAX_FILES,
};
}
export function resolveLogConfig(
configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions,
): ResolvedLogConfig {
const persistedConfig = normalizeLoggerConfigInput(configInput);
const env = options?.env ?? process.env;
const paseoHome = resolveConfiguredPaseoHome(options);
const persistedLog = persistedConfig?.log;
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog);
const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path);
const rotate = resolveRotateConfig(env, persistedLog);
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog);
const file =
options?.file !== false && persistedLog?.file
? {
level: fileLevel ?? DEFAULT_FILE_LEVEL,
path: resolveFilePath(paseoHome, persistedLog.file.path),
}
: undefined;
return {
level: minLogLevel([consoleLevel, fileLevel]),
level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]),
console: {
level: consoleLevel,
format: consoleFormat,
},
file: {
level: fileLevel,
path: filePath,
rotate,
},
...(file ? { file } : {}),
};
}
@@ -278,32 +166,26 @@ export function createRootLogger(
options?: ResolveLogConfigOptions,
): pino.Logger {
const config = resolveLogConfig(configInput, options);
if (config.file) {
mkdirSync(path.dirname(config.file.path), { recursive: true });
}
mkdirSync(path.dirname(config.file.path), { recursive: true });
const consoleStream =
const stream =
config.console.format === "pretty"
? pretty({
colorize: true,
singleLine: true,
ignore: "pid,hostname",
destination: config.file?.path ?? 1,
})
: pino.destination({ dest: 1, sync: false });
rotateOnRestart(config.file.path, config.file.rotate.maxFiles);
const fileStream = createRotatingFileStream(path.basename(config.file.path), {
path: path.dirname(config.file.path),
size: toRotatingFileStreamSize(config.file.rotate.maxSize),
maxFiles: config.file.rotate.maxFiles,
});
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
return pino(
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
pino.multistream([
{ level: config.console.level, stream: consoleStream },
{ level: config.file.level, stream: fileStream },
]),
{
level: config.file?.level ?? config.console.level,
redact: { paths: REDACT_PATHS, remove: true },
},
stream,
);
}

View File

@@ -12,7 +12,6 @@ import {
createPaseoWorktree,
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = [];
@@ -105,32 +104,32 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
});
const deps = createDeps();
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(created.worktree.branchName).toBe("unnamed-placeholder");
expect(created.worktree.branchName).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName: "unnamed-placeholder",
placeholderBranchName: "dazzling-yak",
},
});
const first = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Build the agent context name" },
generateBranchName: deps.generateBranchName,
generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
});
const branchAfterFirst = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
@@ -149,14 +148,14 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "unnamed-placeholder",
placeholderBranchName: "dazzling-yak",
},
});
const second = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Try another name" },
generateBranchName: () => "second-agent-name",
generateBranchNameFromContext: async () => "second-agent-name",
});
const branchAfterSecond = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
@@ -172,10 +171,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
test("renames the branch even when the app supplies a random placeholder slug", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) =>
seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"),
});
const deps = createDeps();
const created = await createPaseoWorktree(
{
@@ -188,6 +184,18 @@ test("renames the branch even when the app supplies a random placeholder slug",
deps,
);
expect(created.worktree.branchName).toBe("dazzling-yak");
expect(created.workspace.displayName).toBe("dazzling-yak");
await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Investigate the failing login flow" },
generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.prompt === "Investigate the failing login flow"
? "renamed-from-prompt"
: null,
});
const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
@@ -195,20 +203,13 @@ test("renames the branch even when the app supplies a random placeholder slug",
.toString()
.trim();
expect(created.worktree.branchName).toBe("renamed-from-prompt");
expect(branchAfter).toBe("renamed-from-prompt");
expect(created.workspace.displayName).toBe("renamed-from-prompt");
});
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) =>
seed?.includes("Investigate flaky checkout test")
? "renamed-from-pr-attachment"
: (seed ?? "fallback"),
});
const deps = createDeps();
const created = await createPaseoWorktree(
{
@@ -231,6 +232,27 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
deps,
);
expect(created.worktree.branchName).toBe("dazzling-yak");
await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: {
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 42,
title: "Investigate flaky checkout test",
url: "https://github.com/acme/repo/pull/42",
},
],
},
generateBranchNameFromContext: async ({ firstAgentContext }) =>
firstAgentContext.attachments?.[0]?.type === "github_pr"
? "renamed-from-pr-attachment"
: null,
});
const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
@@ -238,9 +260,46 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
.toString()
.trim();
expect(created.worktree.branchName).toBe("renamed-from-pr-attachment");
expect(branchAfter).toBe("renamed-from-pr-attachment");
expect(created.workspace.displayName).toBe("renamed-from-pr-attachment");
});
test("leaves the branch alone when generated branch text is invalid", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
firstAgentContext: { prompt: "Name this branch" },
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Name this branch" },
generateBranchNameFromContext: async () => "Invalid Branch Name",
}),
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "dazzling-yak",
},
});
});
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
@@ -271,7 +330,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Rename checkout branch" },
generateBranchName: () => "must-not-rename",
generateBranchNameFromContext: async () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
@@ -307,7 +366,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Rename PR checkout" },
generateBranchName: () => "must-not-rename",
generateBranchNameFromContext: async () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
@@ -350,15 +409,13 @@ function createDeps(options?: {
events?: string[];
projects?: Map<string, PersistedProjectRecord>;
workspaces?: Map<string, PersistedWorkspaceRecord>;
generateBranchName?: (seed: string | undefined) => string;
}): TestDeps {
const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
return {
...createWorktreeCoreDeps(createGitHubServiceStub()),
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
github: createGitHubServiceStub(),
projects,
workspaces,
projectRegistry: {

View File

@@ -12,9 +12,8 @@ import {
type CreateWorktreeCoreDeps,
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import type { WorktreeConfig } from "../utils/worktree.js";
import { validateBranchSlug } from "../utils/worktree.js";
import { renameCurrentBranch } from "../utils/checkout-git.js";
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
readPaseoWorktreeMetadata,
@@ -24,7 +23,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
import type { FirstAgentContext } from "../shared/messages.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
export interface CreatePaseoWorktreeResult {
worktree: WorktreeConfig;
@@ -58,25 +57,18 @@ export async function createPaseoWorktree(
deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps);
if (!buildAgentBranchNameSeed(input.firstAgentContext)) {
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
}
const worktree = await maybeAutoNameCreatedWorktree({
input,
createdWorktree,
deps,
});
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspace = await upsertWorkspaceForWorktree({
inputCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
worktree,
worktree: createdWorktree.worktree,
deps,
});
deps.github.invalidate({ cwd: worktree.worktreePath });
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
return {
worktree,
worktree: createdWorktree.worktree,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
@@ -87,11 +79,15 @@ export async function createPaseoWorktree(
export async function attemptFirstAgentBranchAutoName(options: {
cwd: string;
firstAgentContext: FirstAgentContext | undefined;
generateBranchName: (seed: string | undefined) => string;
generateBranchNameFromContext: (input: {
cwd: string;
firstAgentContext: FirstAgentContext;
}) => Promise<string | null>;
getCurrentBranch?: typeof getCurrentBranch;
renameCurrentBranch?: typeof renameCurrentBranch;
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
if (!seed) {
const firstAgentContext = options.firstAgentContext;
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
return { attempted: false, renamed: false, branchName: null };
}
@@ -109,11 +105,27 @@ export async function attemptFirstAgentBranchAutoName(options: {
return { attempted: false, renamed: false, branchName: null };
}
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
return { attempted: true, renamed: false, branchName: null };
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = options.generateBranchName(seed);
const branchName = await options.generateBranchNameFromContext({
cwd: options.cwd,
firstAgentContext,
});
if (!branchName) {
return { attempted: true, renamed: false, branchName: null };
}
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
if (!validation.valid || branchName === placeholderBranchName) {
return { attempted: true, renamed: false, branchName: null };
}
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
return { attempted: true, renamed: false, branchName: null };
}
@@ -139,33 +151,6 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
});
}
async function maybeAutoNameCreatedWorktree(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
}): Promise<WorktreeConfig> {
const { input, createdWorktree, deps } = options;
const seed = buildAgentBranchNameSeed(input.firstAgentContext);
if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
return createdWorktree.worktree;
}
const branchName = deps.generateBranchName(seed);
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
return createdWorktree.worktree;
}
const renamedBranch = await renameCurrentBranch(
createdWorktree.worktree.worktreePath,
branchName,
);
return {
...createdWorktree.worktree,
branchName: renamedBranch.currentBranch ?? branchName,
};
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
repoRoot: string;

View File

@@ -9,8 +9,8 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
export const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z
.object({

View File

@@ -15,7 +15,6 @@ interface ResolverHarness {
github: GitHubService;
headRefLookups: GitHubHeadRefLookup[];
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
function createResolverHarness(): ResolverHarness {
@@ -51,7 +50,6 @@ function createResolverHarness(): ResolverHarness {
github,
headRefLookups,
resolveDefaultBranch: async () => "main",
generateBranchName: (seed) => seed ?? "generated-worktree",
};
}
@@ -61,10 +59,12 @@ describe("resolveWorktreeCreationIntent", () => {
test("branches off the repo default branch when no explicit fields are set", async () => {
const deps = createResolverHarness();
await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({
await expect(
resolveWorktreeCreationIntent({ worktreeSlug: "generated-worktree" }, repoRoot, deps),
).resolves.toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "generated-worktree",
branchName: "generated-worktree",
});
expect(deps.headRefLookups).toEqual([]);
});
@@ -81,7 +81,7 @@ describe("resolveWorktreeCreationIntent", () => {
).resolves.toEqual({
kind: "branch-off",
baseBranch: "dev",
newBranchName: "feature",
branchName: "feature",
});
expect(deps.headRefLookups).toEqual([]);
});

View File

@@ -3,17 +3,29 @@ import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = WorktreeSource;
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
}
export type ResolveWorktreeCreationIntentInput =
| {
worktreeSlug: string;
refName?: string;
action?: "branch-off";
githubPrNumber?: undefined;
}
| {
worktreeSlug?: string;
refName?: string;
action: "checkout";
githubPrNumber?: number;
}
| {
worktreeSlug?: string;
refName?: string;
action?: undefined;
githubPrNumber: number;
};
export interface ResolveWorktreeCreationIntentDeps {
github: GitHubService;
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
export class MissingCheckoutTargetError extends Error {
@@ -34,7 +46,7 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
newBranchName: deps.generateBranchName(input.worktreeSlug),
branchName: input.worktreeSlug,
};
}
@@ -72,14 +84,14 @@ export async function resolveWorktreeCreationIntent(
return {
kind: "branch-off",
baseBranch: input.refName.trim(),
newBranchName: deps.generateBranchName(input.worktreeSlug),
branchName: input.worktreeSlug,
};
}
return {
kind: "branch-off",
baseBranch: await resolveDefaultBranch(repoRoot, deps),
newBranchName: deps.generateBranchName(input.worktreeSlug),
branchName: input.worktreeSlug,
};
}

View File

@@ -1292,6 +1292,15 @@ describe("session checkout commit handling", () => {
mode: "uncommitted",
includeStructured: true,
});
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
expect.objectContaining({
persistSession: false,
agentConfigOverrides: expect.objectContaining({
title: "Commit generator",
internal: true,
}),
}),
);
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
message: "Update file",
addAll: true,
@@ -1381,6 +1390,15 @@ describe("session checkout pull request creation", () => {
baseRef: "main",
includeStructured: true,
});
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
expect.objectContaining({
persistSession: false,
agentConfigOverrides: expect.objectContaining({
title: "PR generator",
internal: true,
}),
}),
);
expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith(
"/tmp/request-worktree",
{

View File

@@ -207,7 +207,7 @@ import {
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
@@ -3002,7 +3002,7 @@ export class Session {
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`);
}
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
this.scheduleAutoNameWorkspaceBranchForFirstAgent({
workspace: resolvedWorkspace,
firstAgentContext,
});
@@ -3394,15 +3394,35 @@ export class Session {
);
}
private scheduleAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): void {
setTimeout(() => {
void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: input.workspace.cwd },
"Failed to auto-name worktree branch",
);
});
}, 0);
}
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): Promise<PersistedWorkspaceRecord> {
const coreDeps = createWorktreeCoreDeps(this.github);
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext,
generateBranchName: coreDeps.generateBranchName,
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
return generateBranchNameFromFirstAgentContext({
agentManager: this.agentManager,
cwd,
firstAgentContext,
logger: this.sessionLogger,
});
},
});
if (!result.renamed || !result.branchName) {
return input.workspace;
@@ -3877,6 +3897,7 @@ export class Session {
schemaName: "CommitMessage",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
agentConfigOverrides: {
title: "Commit generator",
internal: true,
@@ -3943,6 +3964,7 @@ export class Session {
schemaName: "PullRequest",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
agentConfigOverrides: {
title: "PR generator",
internal: true,
@@ -6373,9 +6395,8 @@ export class Session {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
},
): Promise<CreatePaseoWorktreeResult> {
const coreDeps = createWorktreeCoreDeps(this.github);
const result = await createPaseoWorktree(input, {
...coreDeps,
github: this.github,
...(options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
@@ -6970,6 +6991,8 @@ export class Session {
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {

View File

@@ -55,7 +55,7 @@ async function createBootstrapWorktreeForTest(
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
branchName: options.branchName,
},
runSetup: false,
paseoHome: options.paseoHome,

View File

@@ -0,0 +1,63 @@
import { describe, expect, test, vi } from "vitest";
import type { AgentManager } from "./agent/agent-manager.js";
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
function createLogger() {
return {
warn: vi.fn(),
error: vi.fn(),
};
}
describe("generateBranchNameFromFirstAgentContext", () => {
test("calls the structured generator with first-agent prompt text", async () => {
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
const branch = await generateBranchNameFromFirstAgentContext({
agentManager: {} as AgentManager,
cwd: "/tmp/repo",
firstAgentContext: { prompt: "Fix the login flow" },
logger: createLogger(),
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
});
expect(branch).toBe("fix-login-flow");
expect(generateStructured).toHaveBeenCalledTimes(1);
expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({
cwd: "/tmp/repo",
schemaName: "BranchName",
maxRetries: 2,
agentConfigOverrides: {
title: "Branch name generator",
internal: true,
},
});
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow");
});
test("uses attachment-only context", async () => {
const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" }));
const branch = await generateBranchNameFromFirstAgentContext({
agentManager: {} as AgentManager,
cwd: "/tmp/repo",
firstAgentContext: {
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 42,
title: "Review flaky checkout",
url: "https://github.com/acme/repo/pull/42",
},
],
},
logger: createLogger(),
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
});
expect(branch).toBe("review-flaky-checkout");
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout");
});
});

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
import type { FirstAgentContext } from "../shared/messages.js";
import type { AgentManager } from "./agent/agent-manager.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
interface BranchNameGeneratorLogger {
warn: (obj: object, msg?: string) => void;
error: (obj: object, msg?: string) => void;
}
export interface GenerateBranchNameFromFirstAgentContextOptions {
agentManager: AgentManager;
cwd: string;
firstAgentContext: FirstAgentContext | undefined;
logger: BranchNameGeneratorLogger;
deps?: {
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
};
}
const BranchNameSchema = z.object({
branch: z.string().min(1).max(100),
});
function buildPrompt(seed: string): string {
return [
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
"Return JSON only with a single field 'branch'.",
"",
"User context:",
seed,
].join("\n");
}
export async function generateBranchNameFromFirstAgentContext(
options: GenerateBranchNameFromFirstAgentContextOptions,
): Promise<string | null> {
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
if (!seed) {
return null;
}
const generator =
options.deps?.generateStructuredAgentResponseWithFallback ??
generateStructuredAgentResponseWithFallback;
try {
const result = await generator({
manager: options.agentManager,
cwd: options.cwd,
prompt: buildPrompt(seed),
schema: BranchNameSchema,
schemaName: "BranchName",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
agentConfigOverrides: {
title: "Branch name generator",
internal: true,
},
});
return result.branch.trim() || null;
} catch (error) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
options.logger.warn({ err: error }, "Structured branch name generation failed");
return null;
}
options.logger.error({ err: error }, "Branch name generation failed");
return null;
}
}

View File

@@ -44,17 +44,13 @@ function createGitHubServiceStub(): GitHubService {
};
}
function createCoreDeps(options?: {
github?: GitHubService;
generateBranchName?: (seed: string | undefined) => string;
}) {
function createCoreDeps(options?: { github?: GitHubService }) {
return {
github: options?.github ?? createGitHubServiceStub(),
workspaceGitService: {
resolveRepoRoot: async (cwd: string) => cwd,
},
resolveDefaultBranch: async () => "main",
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
};
}
@@ -191,13 +187,33 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "legacy-rpc",
branchName: "legacy-rpc",
});
expect(result.created).toBe(true);
expect(result.worktree.branchName).toBe("legacy-rpc");
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent.kind).toBe("branch-off");
expect(result.created).toBe(true);
expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath));
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
@@ -259,7 +275,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "mcp-standalone",
branchName: "mcp-standalone",
});
expect(result.worktree.branchName).toBe("mcp-standalone");
});
@@ -290,7 +306,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "dev",
newBranchName: "from-dev",
branchName: "from-dev",
});
expect(mergeBase).toBe(devTip);
});
@@ -500,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "agent-worktree",
branchName: "agent-worktree",
});
expect(result.worktree.branchName).toBe("agent-worktree");
});

View File

@@ -1,4 +1,4 @@
import { v4 as uuidv4 } from "uuid";
import { createNameId } from "mnemonic-id";
import type { GitHubService } from "../services/github-service.js";
import {
@@ -16,8 +16,12 @@ import {
import type { FirstAgentContext } from "../shared/messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
export interface CreateWorktreeCoreInput {
cwd: string;
worktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
firstAgentContext?: FirstAgentContext;
paseoHome?: string;
runSetup?: boolean;
@@ -27,7 +31,6 @@ export interface CreateWorktreeCoreDeps {
github: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
export interface CreateWorktreeCoreResult {
@@ -42,31 +45,51 @@ export async function createWorktreeCore(
deps: CreateWorktreeCoreDeps,
): Promise<CreateWorktreeCoreResult> {
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
const requestedWorktreeSlug = input.worktreeSlug
? normalizeWorktreeSlug(input.worktreeSlug)
: undefined;
const intent = await resolveWorktreeCreationIntent(
{ ...input, worktreeSlug: requestedSlug },
repoRoot,
{
...deps,
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
},
);
let intentInput: ResolveWorktreeCreationIntentInput;
if (input.action === "checkout") {
intentInput = {
action: "checkout",
refName: input.refName,
githubPrNumber: input.githubPrNumber,
worktreeSlug: requestedWorktreeSlug,
};
} else if (input.githubPrNumber !== undefined) {
intentInput = {
githubPrNumber: input.githubPrNumber,
refName: input.refName,
worktreeSlug: requestedWorktreeSlug,
};
} else {
const worktreeSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(createNameId());
intentInput = {
action: "branch-off",
refName: input.refName,
worktreeSlug,
};
}
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
...deps,
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
});
let normalizedSlug: string;
switch (intent.kind) {
case "branch-off": {
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName));
normalizedSlug = intent.branchName;
break;
}
case "checkout-branch": {
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName));
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
break;
}
case "checkout-github-pr": {
normalizedSlug = validateWorktreeSlug(
requestedSlug ?? slugify(intent.localBranchName ?? intent.headRef),
);
normalizedSlug =
requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef);
break;
}
}
@@ -94,13 +117,6 @@ export async function createWorktreeCore(
};
}
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
return {
github,
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
};
}
async function resolveDefaultBranch(
repoRoot: string,
deps: CreateWorktreeCoreDeps,
@@ -132,3 +148,7 @@ function validateWorktreeSlug(slug: string): string {
}
return slug;
}
function normalizeWorktreeSlug(value: string): string {
return validateWorktreeSlug(slugify(value));
}

View File

@@ -39,7 +39,6 @@ import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
@@ -65,7 +64,7 @@ function createLegacyWorktreeForTest(
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
@@ -262,9 +261,8 @@ function createPaseoWorktreeForTest(options: {
});
return (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
return createPaseoWorktreeService(input, {
...coreDeps,
github: createGitHubServiceStub(),
...(serviceOptions?.resolveDefaultBranch
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
: {}),
@@ -1303,7 +1301,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
intent: {
kind: "branch-off" as const,
baseBranch: "main",
newBranchName: "fix-attached-pr-context",
branchName: "fix-attached-pr-context",
},
workspace: {
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
@@ -1445,7 +1443,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(result.intent).toMatchObject({
kind: "branch-off",
baseBranch: "main",
newBranchName: "resolver-feature",
branchName: "resolver-feature",
});
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
});

View File

@@ -115,6 +115,10 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
},
) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
autoNameWorkspaceBranchForFirstAgent?: (input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}) => void;
}
interface AgentWorktreeSetupContinuationInput {
@@ -576,6 +580,12 @@ export async function createPaseoWorktreeWorkflow(
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
setTimeout(() => {
if (input.firstAgentContext) {
dependencies.autoNameWorkspaceBranchForFirstAgent?.({
workspace,
firstAgentContext: input.firstAgentContext,
});
}
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },

View File

@@ -0,0 +1,71 @@
import stripAnsi from "strip-ansi";
import type { TerminalCell } from "../shared/messages.js";
import type { TerminalSession } from "./terminal.js";
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}

View File

@@ -1,10 +0,0 @@
import { expect, it } from "vitest";
import { resolveTerminalBackend } from "./terminal-manager-factory.js";
it("uses the worker terminal backend by default", () => {
expect(resolveTerminalBackend({})).toBe("worker");
});
it("allows explicitly opting back into the in-process terminal backend", () => {
expect(resolveTerminalBackend({ PASEO_TERMINAL_BACKEND: "in-process" })).toBe("in-process");
});

View File

@@ -1,18 +1,6 @@
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import type { TerminalManager } from "./terminal-manager.js";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
export type TerminalBackend = "in-process" | "worker";
export function resolveTerminalBackend(env: NodeJS.ProcessEnv = process.env): TerminalBackend {
return env.PASEO_TERMINAL_BACKEND === "in-process" ? "in-process" : "worker";
}
export function createConfiguredTerminalManager(options?: {
backend?: TerminalBackend;
}): TerminalManager {
const backend = options?.backend ?? resolveTerminalBackend();
if (backend === "worker") {
return createWorkerTerminalManager();
}
return createTerminalManager();
export function createConfiguredTerminalManager(): TerminalManager {
return createWorkerTerminalManager();
}

View File

@@ -1,5 +1,5 @@
import { createTerminalManager } from "./terminal-manager.js";
import { captureTerminalLines } from "./terminal.js";
import { captureTerminalLines } from "./terminal-capture.js";
import type { TerminalSession } from "./terminal.js";
import type {
TerminalWorkerRequest,

View File

@@ -5,7 +5,7 @@ import type {
TerminalStateSnapshot,
} from "./terminal.js";
import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
export interface WorkerTerminalInfo {
id: string;

View File

@@ -6,9 +6,13 @@ import { tmpdir, userInfo } from "node:os";
import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
export {
captureTerminalLines,
type CaptureTerminalLinesOptions,
type CaptureTerminalLinesResult,
} from "./terminal-capture.js";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
@@ -96,17 +100,6 @@ interface BuildTerminalEnvironmentInput {
zshShellIntegrationDir?: string;
}
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
interface EnsureNodePtySpawnHelperExecutableOptions {
packageRoot?: string;
platform?: NodeJS.Platform;
@@ -523,63 +516,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit);
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,

View File

@@ -64,7 +64,7 @@ function createLegacyWorktreeForTest(
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,

View File

@@ -62,7 +62,7 @@ function createLegacyWorktreeForTest(
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
branchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
@@ -182,7 +182,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "my-feature",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
runSetup: true,
paseoHome,
});
@@ -796,7 +796,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "seed-uncommitted",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/seed" },
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
runSetup: false,
paseoHome,
});
@@ -824,7 +824,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "preserve-committed",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/preserve" },
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
runSetup: false,
paseoHome,
});
@@ -839,7 +839,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "no-config",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/no-config" },
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
runSetup: false,
paseoHome,
});

View File

@@ -5,7 +5,6 @@ import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
@@ -30,7 +29,6 @@ import { runGitCommand } from "./run-git-command.js";
import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
const execFileAsync = promisify(execFile);
@@ -150,7 +148,7 @@ export interface PaseoWorktreeOwnership {
}
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| {
kind: "checkout-github-pr";
@@ -465,54 +463,29 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd,
});
const spawnWithPipes = () => {
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
};
try {
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
name: "xterm-color",
cols: 120,
rows: 30,
});
terminal.onData((data) => {
emitOutput("stdout", data);
});
terminal.onExit(({ exitCode }) => {
finish(typeof exitCode === "number" ? exitCode : null);
});
} catch (error) {
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes();
}
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
});
}
@@ -1292,7 +1265,7 @@ async function resolveWorktreeSourcePlan({
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
switch (source.kind) {
case "branch-off": {
const branchName = source.newBranchName;
const branchName = source.branchName;
validateWorktreeBranchName(branchName);
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);

View File

@@ -3,7 +3,7 @@
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.66",
"version": "0.1.69",
"private": true,
"type": "module",
"scripts": {