refactor: simplify worktree script spawning

This commit is contained in:
Mohamed Boudra
2026-04-21 20:46:54 +07:00
parent 79bf5ac3c7
commit 39db3ea5ff
5 changed files with 62 additions and 86 deletions

View File

@@ -3,8 +3,33 @@
## 0.1.60-beta.1 - 2026-04-20 ## 0.1.60-beta.1 - 2026-04-20
### Added ### Added
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable. - Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`. - Launch scripts and services for a worktree directly from the workspace header.
- New Setup tab in every workspace showing setup, teardown, and script progress live.
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
- Pull request pane in the workspace sidebar.
- Redesigned Settings screen with modular section navigation.
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
- New-workspace picker ranks branches by recency with fast search.
### Improved
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
- Provider refresh is reliable and no longer stalls on transient failures.
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
- In-app notifications route to whichever surface you're actually looking at.
- Keyboard shortcuts keep working while Settings is open.
### Fixed
- Composer textarea shrinks back down after sending on web.
- Branch switcher title no longer overflows on narrow rows.
- iOS image picker no longer leaves the screen unresponsive after cancelling.
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
## 0.1.59 - 2026-04-16 ## 0.1.59 - 2026-04-16

View File

@@ -7,7 +7,7 @@ import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ScriptRouteStore } from "./script-proxy.js"; import { findFreePort, ScriptRouteStore } from "./script-proxy.js";
import { ScriptHealthMonitor, type ScriptHealthEntry } from "./script-health-monitor.js"; import { ScriptHealthMonitor, type ScriptHealthEntry } from "./script-health-monitor.js";
import { spawnWorktreeScripts } from "./worktree-bootstrap.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
type TcpServerHandle = { type TcpServerHandle = {
@@ -433,15 +433,19 @@ describe("ScriptHealthMonitor", () => {
[]; [];
try { try {
await spawnWorktreeScripts({ for (const scriptName of ["typecheck", "api"]) {
repoRoot: workspace.repoDir, await spawnWorkspaceScript({
workspaceId: workspace.repoDir, repoRoot: workspace.repoDir,
branchName: null, workspaceId: workspace.repoDir,
daemonPort: null, projectSlug: "repo",
routeStore, branchName: null,
runtimeStore, scriptName,
terminalManager: createStubTerminalManager(createTerminalCalls) as any, daemonPort: null,
}); routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
});
}
expect(createTerminalCalls).toHaveLength(2); expect(createTerminalCalls).toHaveLength(2);
expect(routeStore.listRoutes()).toEqual([ expect(routeStore.listRoutes()).toEqual([

View File

@@ -5,11 +5,7 @@ import { join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
runAsyncWorktreeBootstrap,
spawnWorkspaceScript,
spawnWorktreeScripts,
} from "./worktree-bootstrap.js";
import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -717,17 +713,19 @@ describe("runAsyncWorktreeBootstrap", () => {
const createTerminalCalls: CreateTerminalCall[] = []; const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = []; const terminalRecords: StubTerminalRecord[] = [];
const results = await spawnWorktreeScripts({ const result = await spawnWorkspaceScript({
repoRoot: repoDir, repoRoot: repoDir,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-socket-service", branchName: "feature-socket-service",
scriptName: "web",
daemonPort: null, daemonPort: null,
routeStore, routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
expect(results).toHaveLength(1); expect(result).toBeDefined();
expect(routeStore.listRoutes()).toEqual([]); expect(routeStore.listRoutes()).toEqual([]);
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.cwd).toBe(repoDir); expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
@@ -1344,15 +1342,19 @@ describe("runAsyncWorktreeBootstrap", () => {
const terminalManager = createTerminalManager(); const terminalManager = createTerminalManager();
realTerminalManagers.push(terminalManager); realTerminalManagers.push(terminalManager);
await spawnWorktreeScripts({ for (const scriptName of ["api", "web"]) {
repoRoot: repoDir, await spawnWorkspaceScript({
workspaceId: repoDir, repoRoot: repoDir,
branchName: "feature-peer-env", workspaceId: repoDir,
daemonPort: 6767, projectSlug: "repo",
routeStore, branchName: "feature-peer-env",
runtimeStore, scriptName,
terminalManager, daemonPort: 6767,
}); routeStore,
runtimeStore,
terminalManager,
});
}
const apiEnvPath = join(repoDir, "api-env.json"); const apiEnvPath = join(repoDir, "api-env.json");
const webEnvPath = join(repoDir, "web-env.json"); const webEnvPath = join(repoDir, "web-env.json");
@@ -1422,10 +1424,12 @@ describe("runAsyncWorktreeBootstrap", () => {
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = []; const createTerminalCalls: CreateTerminalCall[] = [];
await spawnWorktreeScripts({ await spawnWorkspaceScript({
repoRoot: repoDir, repoRoot: repoDir,
workspaceId: repoDir, workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-remote-service", branchName: "feature-remote-service",
scriptName: "web",
daemonPort: 6767, daemonPort: 6767,
daemonListenHost: "100.64.0.20", daemonListenHost: "100.64.0.20",
routeStore, routeStore,

View File

@@ -3,7 +3,6 @@ import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
import { buildScriptHostname } from "../utils/script-hostname.js"; import { buildScriptHostname } from "../utils/script-hostname.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import { import {
getScriptConfigs, getScriptConfigs,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
@@ -922,39 +921,6 @@ export async function spawnWorkspaceScript(
} }
} }
export async function spawnWorktreeScripts(options: {
repoRoot: string;
workspaceId: string;
branchName: string | null;
daemonPort?: number | null;
daemonListenHost?: string | null;
routeStore: ScriptRouteStore;
runtimeStore: WorkspaceScriptRuntimeStore;
terminalManager: TerminalManager;
logger?: Logger;
onLifecycleChanged?: () => void;
}): Promise<WorktreeScriptResult[]> {
const { repoRoot } = options;
const projectSlug = deriveProjectSlug(repoRoot);
const scriptConfigs = getScriptConfigs(repoRoot);
if (scriptConfigs.size === 0) {
return [];
}
const results: WorktreeScriptResult[] = [];
for (const scriptName of scriptConfigs.keys()) {
results.push(
await spawnWorkspaceScript({
...options,
projectSlug,
scriptName,
}),
);
}
return results;
}
export function teardownWorktreeScripts(options: { export function teardownWorktreeScripts(options: {
hostnames: string[]; hostnames: string[];
routeStore: ScriptRouteStore; routeStore: ScriptRouteStore;

View File

@@ -20,7 +20,6 @@ import {
buildWorktreeSetupDetail, buildWorktreeSetupDetail,
createWorktreeSetupProgressAccumulator, createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults, getWorktreeSetupProgressResults,
spawnWorktreeScripts,
} from "./worktree-bootstrap.js"; } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
@@ -738,28 +737,6 @@ export async function runWorktreeSetupInBackground(
emitSetupProgress("completed", null); emitSetupProgress("completed", null);
} }
} }
if (
options.shouldBootstrap &&
dependencies.terminalManager &&
dependencies.scriptRouteStore &&
dependencies.scriptRuntimeStore
) {
await spawnWorktreeScripts({
repoRoot: worktree.worktreePath,
workspaceId: options.workspaceId,
branchName: worktree.branchName,
daemonPort: dependencies.getDaemonTcpPort?.() ?? null,
daemonListenHost: dependencies.getDaemonTcpHost?.() ?? null,
routeStore: dependencies.scriptRouteStore,
runtimeStore: dependencies.scriptRuntimeStore,
terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger,
onLifecycleChanged: () => {
dependencies.onScriptsChanged?.(options.workspaceId, worktree.worktreePath);
},
});
}
} catch (error) { } catch (error) {
if (error instanceof WorktreeSetupError) { if (error instanceof WorktreeSetupError) {
setupResults = error.results; setupResults = error.results;