diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b08b3dbcf2..f61d514727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,14 +418,6 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Run Android module tests - run: npm run test:android --workspace=@getpaseo/app - playwright-1: name: playwright (shard 1/8) needs: changes diff --git a/.oxlintrc.json b/.oxlintrc.json index 7a6192215e..c82b517844 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -203,6 +203,31 @@ "readFileSync", "rmSync" ], + // Terminal child process: launcher readiness and PTY record ownership run outside the daemon. + "terminal/terminal-worker-process.ts": [ + "chmodSync", + "closeSync", + "existsSync", + "openSync", + "rmSync", + "writeSync" + ], + // Worker startup only: choose and secure the local listening socket before accepting a controller. + "terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"], + // Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads. + "terminal/terminal-worker-runtime.ts": [ + "chmodSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmSync", + "statSync", + "writeFileSync" + ], + // TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together. + "terminal/paseo-cli-path.ts": ["existsSync"], // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. "terminal/terminal-ts-loader.mjs": ["existsSync"], // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. diff --git a/CHANGELOG.md b/CHANGELOG.md index 18454f14e6..1c790c6f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Voice settings now let you configure speech providers and download local models ### Added +- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100)) +- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117)) - Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938)) - Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) - Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983)) @@ -21,6 +23,8 @@ Voice settings now let you configure speech providers and download local models ### Improved +- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081)) + - Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 51f974221f..062e46cd13 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -21,6 +21,8 @@ ### 改进 +- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) + - 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) diff --git a/CLAUDE.md b/CLAUDE.md index 3f1731597c..d72205a01e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | | [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop | | [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | | [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | diff --git a/docs/android.md b/docs/android.md index 5a59eedaa4..546ba92a3e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo `android:production` still builds and installs a release through Expo; use `build:apk` when you need an artifact. `android:clear` removes the generated Android project. -Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain. +Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests. ### Diagnostic logs without ADB diff --git a/docs/architecture.md b/docs/architecture.md index 29f9c7f817..3c5678b0ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf retaining the daemon's runtime and wire-schema allocations. The server export tests guard these dependency trees with tree-shaking disabled to match unbundled production imports. +Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that +survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md). + Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs diff --git a/docs/design.md b/docs/design.md index a336b80a64..d115913789 100644 --- a/docs/design.md +++ b/docs/design.md @@ -203,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as ` + {terminalsSurviveRestart ? ( + + + + {t("settings.host.daemon.restart.closeTerminalsHint")} + + + + + ) : null} + {restartError ? ( + + + + ) : null} ); } @@ -848,7 +894,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { /> ) : null} {updateState.status === "failed" ? ( - + ({ })); const styles = StyleSheet.create((theme) => ({ - updateFailure: { + cardAlert: { marginHorizontal: theme.spacing[4], marginBottom: theme.spacing[4], }, diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 5713a06de3..aeb0d2ed32 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -112,7 +112,7 @@ function appendRun(input: { const run = createRun(input.calls, input.isSealed); const host = createHost(run); input.output.push(host); - input.groups.set(host.id, input.buildGroup(run)); + if (run.calls.length > 1) input.groups.set(host.id, input.buildGroup(run)); } export function prepareGroupedHistory(input: { @@ -180,7 +180,8 @@ export function groupLiveToolCalls(input: { if (hostPlacement === "head") { head.push(createHost(run)); } - if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + const needsLiveGroup = hostPlacement === "head" || pendingIncludesHead || !isSealed; + if (run.calls.length > 1 && needsLiveGroup) { liveGroups.set(run.id, input.buildGroup(run)); } pending = []; @@ -217,19 +218,13 @@ export function groupLiveToolCalls(input: { historyGroupUpdatesByHostId: EMPTY_GROUPS, }; } - if (input.history.groupsByHostId.size === 0) { - return { - tail: input.history.tail, - head, - groupsByHostId: liveGroups, - historyGroupUpdatesByHostId: EMPTY_GROUPS, - }; - } const groupsByHostId = new Map(input.history.groupsByHostId); let historyGroupUpdatesByHostId: Map | null = null; for (const [id, group] of liveGroups) { groupsByHostId.set(id, group); - if (input.history.groupsByHostId.has(id)) { + const isHistoryHost = + input.history.groupsByHostId.has(id) || input.history.pendingCalls[0]?.id === id; + if (isHistoryHost) { historyGroupUpdatesByHostId ??= new Map(); historyGroupUpdatesByHostId.set(id, group); } diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index b3b5bf39eb..2888f1f424 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -4,8 +4,12 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + type ContextBridge, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet"; +import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context"; import type { Theme } from "@/styles/theme"; interface OverviewToolCallGroupSheetProps { @@ -29,7 +33,12 @@ export function OverviewToolCallGroupSheet({ }: OverviewToolCallGroupSheetProps) { const { t } = useTranslation(); const scrollRef = useRef(null); - const contextBridge = useToolCallSheetContextBridge(); + const sheetContextBridge = useToolCallSheetContextBridge(); + const expansionContextBridge = useToolCallExpansionContextBridge(); + const contextBridge = useCallback( + (content) => sheetContextBridge(expansionContextBridge(content)), + [expansionContextBridge, sheetContextBridge], + ); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, onClose, diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index d6233854a2..5c4b4a900b 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -110,11 +110,7 @@ describe("tool call detail-level projection", () => { preparedHistory: prepared, }); expect(single.head).toEqual([firstCall]); - expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ - calls: [firstCall], - latest: firstCall, - isSealed: false, - }); + expect(single.groupsByHostId.size).toBe(0); const grouped = project({ level: "overview", @@ -142,18 +138,11 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); }); - it("builds a loading aggregate for a one-call run", () => { + it("leaves a running lone call visible without an aggregate", () => { const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); const result = project({ level: "overview", head: [call], isTurnActive: true }); - const group = result.groupsByHostId.get(call.id); - if (!group) { - throw new Error("Expected an overview group"); - } - - expect(group).toMatchObject({ - isLoading: true, - summary: { commandCount: 1 }, - }); + expect(result.head).toEqual([call]); + expect(result.groupsByHostId.size).toBe(0); }); it("keeps an active overview group on its latest call until a visible boundary arrives", () => { @@ -481,8 +470,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: [singleCall, plan, speak] }); expect(result.head).toEqual([singleCall, plan, speak]); - expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); - expect(result.groupsByHostId.size).toBe(1); + expect(result.groupsByHostId.size).toBe(0); }); }); @@ -529,12 +517,58 @@ it.each([false, true])( }); expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( - autoExpandReasoning ? [call] : [call, thought], + autoExpandReasoning ? undefined : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toEqual( + autoExpandReasoning + ? undefined + : expect.objectContaining({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: 1, + }), ); - expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({ - commandCount: 1, - otherToolCount: 0, - thoughtCount: autoExpandReasoning ? 0 : 1, - }); }, ); + +it("turns a retained lone call into a group without replacing its row key", () => { + const first = toolCall("1", { type: "shell", command: "one" }); + const second = toolCall("2", { type: "shell", command: "two" }); + const tail = [assistant("before"), first]; + const preparedHistory = prepareToolCallHistory("overview", tail); + const single = project({ level: "overview", tail, preparedHistory, isTurnActive: true }); + const grouped = project({ + level: "overview", + tail, + head: [second], + preparedHistory, + isTurnActive: true, + }); + expect(single.groupsByHostId.size).toBe(0); + expect(single.tail).toBe(tail); + expect(grouped.tail).toBe(single.tail); + expect(grouped.head).toEqual([]); + expect(grouped.groupsByHostId.get(first.id)?.run.calls).toEqual([first, second]); + expect(grouped.historyGroupUpdatesByHostId.get(first.id)).toBe( + grouped.groupsByHostId.get(first.id), + ); +}); + +it("shows one thought as itself until another groupable item arrives", () => { + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Checking", + status: "ready", + timestamp: new Date(0), + }; + const single = project({ level: "overview", tail: [thought] }); + expect(single.tail).toEqual([thought]); + expect(single.groupsByHostId.size).toBe(0); + const grouped = project({ + level: "overview", + tail: [thought, toolCall("1", { type: "shell", command: "one" })], + }); + expect(grouped.tail.map((item) => item.id)).toEqual([thought.id]); + expect(grouped.groupsByHostId.get(thought.id)?.run.calls).toHaveLength(2); +}); diff --git a/packages/app/src/tool-calls/expansion-context.tsx b/packages/app/src/tool-calls/expansion-context.tsx new file mode 100644 index 0000000000..4ac2477247 --- /dev/null +++ b/packages/app/src/tool-calls/expansion-context.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + createToolCallExpansion, + type ExpandedToolCallGroups, + type ToolCallExpansion, +} from "./expansion"; + +export const ToolCallExpansionContext = createContext(null); + +/** One store per viewed agent; switching agents starts with every tool call closed. */ +export function useAgentToolCallExpansion(agentId: string): ToolCallExpansion { + const [owner, setOwner] = useState(() => ({ agentId, expansion: createToolCallExpansion() })); + if (owner.agentId === agentId) return owner.expansion; + const next = { agentId, expansion: createToolCallExpansion() }; + setOwner(next); + return next.expansion; +} + +/** Subscribes the timeline to group toggles only; call toggles never reach it. */ +export function useExpandedToolCallGroups(expansion: ToolCallExpansion): ExpandedToolCallGroups { + return useSyncExternalStore( + expansion.subscribeGroups, + expansion.getExpandedGroups, + expansion.getExpandedGroups, + ); +} + +function useToolCallExpansion(): ToolCallExpansion { + const expansion = useContext(ToolCallExpansionContext); + if (!expansion) throw new Error("ToolCall requires a ToolCallExpansionContext"); + return expansion; +} + +/** Carries tool call open state into sheets that render their content in a portal. */ +export function useToolCallExpansionContextBridge(): ContextBridge { + const expansion = useToolCallExpansion(); + return useCallback( + (children) => ( + + {children} + + ), + [expansion], + ); +} + +/** Subscribes one tool call to its own open state. */ +export function useToolCallExpanded(input: { + callId: string; + defaultExpanded: boolean; + standalone: boolean; +}) { + const expansion = useToolCallExpansion(); + const { callId, defaultExpanded, standalone } = input; + const subscribe = useCallback( + (listener: () => void) => expansion.subscribeCall(callId, listener), + [expansion, callId], + ); + const getSnapshot = useCallback( + () => expansion.isCallExpanded(callId, defaultExpanded), + [expansion, callId, defaultExpanded], + ); + const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const toggle = useCallback(() => { + expansion.setCallExpanded({ callId, expanded: !expanded, standalone }); + }, [expansion, callId, expanded, standalone]); + return { expanded, toggle }; +} diff --git a/packages/app/src/tool-calls/expansion.test.ts b/packages/app/src/tool-calls/expansion.test.ts new file mode 100644 index 0000000000..875d3c6078 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createToolCallExpansion } from "./expansion"; + +function observe(expansion: ReturnType) { + const notified: string[] = []; + expansion.subscribeCall("first", () => notified.push("first")); + expansion.subscribeCall("second", () => notified.push("second")); + expansion.subscribeGroups(() => notified.push("timeline")); + return notified; +} + +describe("tool call expansion", () => { + it("opens a lone call and its future group without notifying the timeline", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + + expect(notified).toEqual(["first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + }); + + it("closes the lone call and its future group together", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + + expect(notified).toEqual(["first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(false); + expect(expansion.getExpandedGroups().has("first")).toBe(false); + }); + + it("keeps a call open when its group closes and reopens", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + const groupsWhenFormed = expansion.getExpandedGroups(); + + expansion.setGroupExpanded("first", false); + const closedGroups = expansion.getExpandedGroups(); + expect(closedGroups).not.toBe(groupsWhenFormed); + expect(closedGroups.has("first")).toBe(false); + expect(expansion.isCallExpanded("first", false)).toBe(true); + + expansion.setGroupExpanded("first", true); + expect(expansion.getExpandedGroups()).not.toBe(closedGroups); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(notified).toEqual(["first", "timeline", "timeline"]); + }); + + it("toggles a call inside a group without touching the group", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setGroupExpanded("first", true); + const groups = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: false }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: false }); + + expect(notified).toEqual(["timeline", "first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groups); + expect(groups.has("first")).toBe(true); + }); + + it("uses the call's default until the user toggles it", () => { + const expansion = createToolCallExpansion(); + expect(expansion.isCallExpanded("first", true)).toBe(true); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + expect(expansion.isCallExpanded("first", true)).toBe(false); + }); +}); diff --git a/packages/app/src/tool-calls/expansion.ts b/packages/app/src/tool-calls/expansion.ts new file mode 100644 index 0000000000..d11a824ae2 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.ts @@ -0,0 +1,67 @@ +type Listener = () => void; + +/** Which tool call groups are open. A new value is published only when a group is toggled. */ +export interface ExpandedToolCallGroups { + has(groupId: string): boolean; +} + +/** + * Open state of one agent's tool calls and tool call groups. + * + * A group takes its first call's id. Opening a lone call (`standalone`) also opens the group it + * will host once a second call arrives, without publishing a group change: the timeline renders + * that row again when the group forms and reads the open state then. A call toggle notifies only + * that call's subscribers. + */ +export function createToolCallExpansion() { + const calls = new Map(); + const callListeners = new Map>(); + const groups = new Set(); + const groupListeners = new Set(); + let expandedGroups = createExpandedGroups(); + + function createExpandedGroups(): ExpandedToolCallGroups { + return { has: (groupId) => groups.has(groupId) }; + } + + function writeGroup(groupId: string, expanded: boolean) { + if (expanded) groups.add(groupId); + else groups.delete(groupId); + } + + return { + isCallExpanded: (callId: string, defaultExpanded: boolean) => + calls.get(callId) ?? defaultExpanded, + subscribeCall(callId: string, listener: Listener) { + let listeners = callListeners.get(callId); + if (!listeners) { + listeners = new Set(); + callListeners.set(callId, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) callListeners.delete(callId); + }; + }, + setCallExpanded(input: { callId: string; expanded: boolean; standalone: boolean }) { + calls.set(input.callId, input.expanded); + if (input.standalone) writeGroup(input.callId, input.expanded); + for (const listener of callListeners.get(input.callId) ?? []) listener(); + }, + getExpandedGroups: () => expandedGroups, + subscribeGroups(listener: Listener) { + groupListeners.add(listener); + return () => { + groupListeners.delete(listener); + }; + }, + setGroupExpanded(groupId: string, expanded: boolean) { + writeGroup(groupId, expanded); + expandedGroups = createExpandedGroups(); + for (const listener of groupListeners) listener(); + }, + }; +} + +export type ToolCallExpansion = ReturnType; diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index ef88e39a23..97e2fbf604 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -3,8 +3,11 @@ import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError, + retryUnconfirmedTerminalCleanup, + TerminalCleanupNotConfirmedError, } from "@getpaseo/server/daemon-control"; import { setTimeout as delay } from "node:timers/promises"; +import type { DaemonGetStatusResponse } from "@getpaseo/protocol/messages"; import { connectToDaemon } from "../../utils/client.js"; import { withOutput, type CommandOptions } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; @@ -20,13 +23,30 @@ export function daemonRestartCommand(): Command { ), ) .option("--timeout ", "Replacement readiness deadline (default: 600)") + .option("--kill-terminals", "Close every terminal instead of keeping them across the restart") .action(withOutput(runRestartCommand)); } +type DaemonStatus = DaemonGetStatusResponse["payload"]; + export async function runRestartCommand(options: CommandOptions, _command: Command) { const target = options.daemonTarget; const deadline = Date.now() + parseTimeoutMs(options.timeout); const remaining = () => Math.max(1, deadline - Date.now()); + const killTerminals = options.killTerminals === true; + // The supervisor reaps before the respawn and the replacement retries what that left; + // anything still named by a closing record survived both. A local caller retries once more + // itself; a remote one has only what the replacement reports. + const confirmTerminalsClosed = async (status: DaemonStatus) => { + if (!killTerminals) return; + if (target.kind === "instance") { + await retryUnconfirmedTerminalCleanup(target.home); + return; + } + // Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup. + const unconfirmed = status.terminalCleanupUnconfirmed ?? []; + if (unconfirmed.length > 0) throw new TerminalCleanupNotConfirmedError(unconfirmed); + }; const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; async function checkSupervisor() { if (target.kind !== "instance") return; @@ -54,7 +74,10 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; await checkSupervisor(); try { - await client.restartServer("cli_restart", undefined, { timeout: remaining() }); + await client.restartServer("cli_restart", undefined, { + timeout: remaining(), + killTerminals, + }); acknowledged = true; } catch (error) { if (!isReconnectFailure(error)) throw error; @@ -77,6 +100,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma const status = await replacement.getDaemonStatus({ timeout: remaining() }); if (status.pid !== workerPid) { await checkSupervisor(); + await confirmTerminalsClosed(status); return { type: "single" as const, data: { diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 14c20534e6..cd499ea6cb 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -103,6 +103,7 @@ async function probeDaemonStatus( daemonNode: status.nodePath, providers: status.providers, relay: status.relay, + ...(status.terminalsUnavailable ? { terminals: status.terminalsUnavailable } : {}), }; } } finally { diff --git a/packages/cli/src/commands/daemon/stop.test.ts b/packages/cli/src/commands/daemon/stop.test.ts new file mode 100644 index 0000000000..7ee5f930f9 --- /dev/null +++ b/packages/cli/src/commands/daemon/stop.test.ts @@ -0,0 +1,110 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, describe, expect, test } from "vitest"; +import { createStopCommandRunner, type StopCommandDeps } from "./stop.js"; + +// The daemon the gate is checked against: a connection whose server_info may or may not +// advertise terminalsSurviveRestart, and that records any shutdown it is asked for. +function createFakeDaemon(features: { terminalsSurviveRestart?: boolean } = {}) { + const shutdownRequests: Array<{ keepTerminals?: boolean }> = []; + const deps: StopCommandDeps = { + connectToDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "fake-daemon", + version: "0.11.0", + features, + }), + shutdownServer: async (options) => { + shutdownRequests.push({ keepTerminals: options?.keepTerminals }); + return { status: "shutdown_requested", clientId: "fake-client", requestId: "fake-request" }; + }, + close: async () => undefined, + }), + }; + return { deps, shutdownRequests }; +} + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function stop(options: Record, deps: StopCommandDeps) { + return createStopCommandRunner(deps)(options as never, new Command("stop")); +} + +// A home whose lock names a live stand-in supervisor, so the local stop path runs for real. +function homeWithRunningSupervisor(): { home: string; supervisor: ChildProcess } { + const home = mkdtempSync(path.join(tmpdir(), "paseo-stop-keep-")); + const supervisor = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { + stdio: "ignore", + }); + writeFileSync( + path.join(home, "paseo.pid"), + JSON.stringify({ + pid: supervisor.pid, + startedAt: new Date().toISOString(), + hostname: hostname(), + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:1", + }), + ); + cleanups.push(() => { + supervisor.kill("SIGKILL"); + rmSync(home, { recursive: true, force: true }); + }); + return { home, supervisor }; +} + +describe("daemon stop --keep-terminals", () => { + test("refuses an endpoint daemon that does not advertise terminalsSurviveRestart", async () => { + const daemon = createFakeDaemon(); + await expect( + stop( + { daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true }, + daemon.deps, + ), + ).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" }); + expect(daemon.shutdownRequests).toEqual([]); + }); + + test.skipIf(process.platform === "win32")( + "refuses a local daemon without the capability and never signals its supervisor", + async () => { + const daemon = createFakeDaemon(); + const { home, supervisor } = homeWithRunningSupervisor(); + await expect( + stop({ daemonTarget: { kind: "instance", home }, keepTerminals: true }, daemon.deps), + ).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" }); + expect(daemon.shutdownRequests).toEqual([]); + expect(supervisor.exitCode).toBeNull(); + expect(supervisor.signalCode).toBeNull(); + }, + ); + + test("asks a capable daemon to keep its terminals", async () => { + const daemon = createFakeDaemon({ terminalsSurviveRestart: true }); + await stop( + { daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true }, + daemon.deps, + ); + expect(daemon.shutdownRequests).toEqual([{ keepTerminals: true }]); + }); + + test("rejects --force with --keep-terminals before contacting the daemon", async () => { + const daemon = createFakeDaemon({ terminalsSurviveRestart: true }); + const { home } = homeWithRunningSupervisor(); + await expect( + stop( + { daemonTarget: { kind: "instance", home }, keepTerminals: true, force: true }, + daemon.deps, + ), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); + expect(daemon.shutdownRequests).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index f2bf8b1255..141d50965e 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; +import { + requireTerminalKeepCapability, + type DaemonClient, +} from "@getpaseo/client/internal/daemon-client"; import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control"; -import { connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon, type ConnectOptions } from "../../utils/client.js"; import { withOutput, type CommandOptions } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { describeDaemonTarget } from "../../utils/daemon-target.js"; @@ -10,19 +14,43 @@ export function daemonStopCommand(): Command { return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) .option("--timeout ", "Graceful exit deadline (default: 15)") .option("--force", "Permit forced local process cleanup") + .option( + "--keep-terminals", + "Leave terminals running; the next daemon started for this home takes them back", + ) .option("--kill-timeout ", "Forced exit deadline (default: 3)") .action(withOutput(runStopCommand)); } -export async function runStopCommand(options: CommandOptions, _command: Command) { +// The daemon connection stop needs; stop.test.ts passes an in-memory adapter. +export type StopDaemonConnection = Pick< + DaemonClient, + "getLastServerInfoMessage" | "shutdownServer" | "close" +>; + +export interface StopCommandDeps { + connectToDaemon(options: ConnectOptions): Promise; +} + +export const runStopCommand = createStopCommandRunner({ connectToDaemon }); + +export function createStopCommandRunner(deps: StopCommandDeps) { + return (options: CommandOptions, _command: Command) => runStop(options, deps); +} + +async function runStop(options: CommandOptions, deps: StopCommandDeps) { const target = options.daemonTarget; const timeoutMs = parseTimeoutMs(options.timeout, 15_000); const deadline = Date.now() + timeoutMs; const remaining = () => Math.max(1, deadline - Date.now()); + const keepTerminals = options.keepTerminals === true; const requestShutdown = async (instance?: DaemonInstance) => { - const client = await connectToDaemon({ target, timeout: remaining(), instance }); + const client = await deps.connectToDaemon({ target, timeout: remaining(), instance }); try { - await client.shutdownServer({ timeout: remaining() }); + // Checked on the connection that carries the shutdown: a daemon without the capability + // would close the terminals it was asked to keep. + if (keepTerminals) requireTerminalKeepCapability(client, describeDaemonTarget(target)); + await client.shutdownServer({ timeout: remaining(), keepTerminals }); } finally { await client.close(); } @@ -32,6 +60,11 @@ export async function runStopCommand(options: CommandOptions, _command: Command) code: "INVALID_OPTIONS", message: "--force requires a local --home; an endpoint gives no remote process authority.", }; + if (options.force && keepTerminals) + throw { + code: "INVALID_OPTIONS", + message: "--force kills terminals; it cannot be combined with --keep-terminals.", + }; const result: { action: string; home?: string; @@ -44,6 +77,7 @@ export async function runStopCommand(options: CommandOptions, _command: Command) ? { ...(await stopDaemonInstance(target.home, { force: options.force === true, + keepTerminals, timeoutMs, killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), requestShutdown, diff --git a/packages/cli/tests/45-daemon-terminals-survive.test.ts b/packages/cli/tests/45-daemon-terminals-survive.test.ts new file mode 100644 index 0000000000..7804ea6917 --- /dev/null +++ b/packages/cli/tests/45-daemon-terminals-survive.test.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env npx tsx + +/** + * Terminals survive `paseo daemon restart` and `stop --keep-terminals`; `restart + * --kill-terminals`, `stop` and `stop --force` close them. Runs the built CLI and daemon in a + * throwaway home; every process it starts is stopped in `finally`. + */ + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.ts"; + +if (process.platform === "win32") { + console.log("Skipping terminal survival CLI test on Windows (POSIX shell terminals)."); + process.exit(0); +} + +console.log("=== Daemon terminals survive restart ===\n"); + +const paseoHome = await mkdtemp(join(tmpdir(), "paseo-terminals-survive-")); +const workDir = join(paseoHome, "work"); +await mkdir(workDir, { recursive: true }); +const port = await getAvailablePort(); +const env = { + PASEO_HOME: paseoHome, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + // The daemon inherits this; terminals then run a plain POSIX shell with no startup files. + SHELL: "/bin/sh", +}; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | false, + message: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await probe(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(message); +} + +async function paseo(args: string[], expectSuccess = true) { + const result = await runLocalPaseo(args, env); + if (expectSuccess) { + assert.equal( + result.exitCode, + 0, + `paseo ${args.join(" ")} failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return result; +} + +async function json(args: string[]): Promise { + return JSON.parse((await paseo([...args, "--json"])).stdout) as T; +} + +async function capture(terminalId: string): Promise { + const { lines } = await json<{ lines: string[] }>(["terminal", "capture", terminalId]); + return lines.join("\n"); +} + +async function waitForScreen(terminalId: string, text: string): Promise { + await waitFor( + async () => (await capture(terminalId)).includes(text), + `terminal ${terminalId} never showed ${text}:\n${await capture(terminalId)}`, + ); +} + +async function terminalIds(): Promise { + return (await json>(["terminal", "ls", "--all"])).map((t) => t.id); +} + +// Worker records and the cleanup records a kill that could not finish leaves beside them. +function workerRecordCount(): number { + const dir = join(paseoHome, "terminal-worker"); + return existsSync(dir) + ? readdirSync(dir).filter((name) => /^(\d+|closing-.+)\.json$/.test(name)).length + : 0; +} + +// A shell terminal that reports its pid and prints a marker. +async function openTerminal(name: string): Promise<{ id: string; pid: number }> { + const terminal = await json<{ id: string }>([ + "terminal", + "create", + "--cwd", + workDir, + "--name", + name, + ]); + const pidFile = join(workDir, `${name}.pid`); + await paseo([ + "terminal", + "send-keys", + terminal.id, + "-l", + `echo $$ > '${pidFile}'; echo MARKER-${name}`, + ]); + await paseo(["terminal", "send-keys", terminal.id, "Enter"]); + const pid = await waitFor( + () => + existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() + ? Number(readFileSync(pidFile, "utf8")) + : null, + `terminal ${name} never wrote its pid`, + ); + await waitForScreen(terminal.id, `MARKER-${name}`); + return { id: terminal.id, pid }; +} + +async function expectKept(terminal: { id: string; pid: number }, name: string, trigger: string) { + assert((await terminalIds()).includes(terminal.id), `${trigger}: terminal is still listed`); + assert(isAlive(terminal.pid), `${trigger}: terminal program is still alive`); + assert((await capture(terminal.id)).includes(`MARKER-${name}`), `${trigger}: marker is kept`); + await paseo(["terminal", "send-keys", terminal.id, "-l", `echo after-${trigger}`]); + await paseo(["terminal", "send-keys", terminal.id, "Enter"]); + await waitForScreen(terminal.id, `\nafter-${trigger}`); +} + +async function expectClosed(pid: number, trigger: string) { + await waitFor(() => !isAlive(pid), `${trigger}: terminal program ${pid} is still alive`, 15_000); +} + +// A zombie: a real process with a birth time that SIGKILL cannot remove while its parent (an +// exec'd `sleep` that never waits) lives. Cleanup can only report it as still alive. +async function startZombie(): Promise<{ zombiePid: number; parentPid: number }> { + const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], { + stdio: ["ignore", "pipe", "ignore"], + }); + const zombiePid = await new Promise((resolve) => { + parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim()))); + }); + parent.unref(); + await waitFor( + async () => (await psField(zombiePid, "stat")).startsWith("Z"), + `process ${zombiePid} never became a zombie`, + ); + return { zombiePid, parentPid: parent.pid! }; +} + +async function psField(pid: number, field: "stat" | "lstart"): Promise { + const child = spawn("ps", ["-o", `${field}=`, "-p", String(pid)], { + env: { ...process.env, LC_ALL: "C" }, + stdio: ["ignore", "pipe", "ignore"], + }); + let out = ""; + child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString())); + await new Promise((resolve) => child.once("close", resolve)); + return out.trim(); +} + +async function goneProcessPid(): Promise { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((resolve) => child.once("exit", resolve)); + return child.pid!; +} + +// A record as a dead terminal worker leaves it, naming one terminal program that is a zombie. +async function plantRecordNaming(programPid: number): Promise { + const workerPid = await goneProcessPid(); + const record = { + recordId: "planted-record", + pid: workerPid, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: join(paseoHome, "planted-worker.sock"), + secret: "planted-secret", + terminals: [ + { id: "planted-terminal", pid: programPid, startedAt: await psField(programPid, "lstart") }, + ], + }; + writeFileSync( + join(paseoHome, "terminal-worker", `${workerPid}.json`), + `${JSON.stringify(record, null, 2)}\n`, + { mode: 0o600 }, + ); +} + +const trackedPids: number[] = []; + +try { + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ] as const) { + await paseo(["daemon", "config", "set", field, value]); + } + await paseo(["daemon", "start"]); + + console.log("Test 1: daemon restart keeps the terminal"); + const kept = await openTerminal("kept"); + trackedPids.push(kept.pid); + await paseo(["daemon", "restart", "--home", paseoHome]); + await expectKept(kept, "kept", "restart"); + console.log("✓ same id, same program, marker and input after restart\n"); + + console.log("Test 2: stop --keep-terminals, then start, gives the terminal back"); + const forceAndKeep = await paseo( + ["daemon", "stop", "--home", paseoHome, "--force", "--keep-terminals"], + false, + ); + assert.notEqual(forceAndKeep.exitCode, 0, "--force with --keep-terminals is refused"); + await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]); + assert.equal(await readDaemonInstance(paseoHome), null, "the supervisor stopped"); + assert(isAlive(kept.pid), "the program outlives the stopped daemon"); + await paseo(["daemon", "start"]); + await expectKept(kept, "kept", "keep-stop"); + console.log("✓ adopted with the same id and still usable\n"); + + console.log("Test 3: a plain stop with no daemon running closes kept terminals"); + await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]); + assert(isAlive(kept.pid), "kept again"); + await paseo(["daemon", "stop", "--home", paseoHome]); + await expectClosed(kept.pid, "stop without a supervisor"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + console.log("Test 4: restart --kill-terminals closes the terminal"); + await paseo(["daemon", "start"]); + const killedByRestart = await openTerminal("restart-kill"); + trackedPids.push(killedByRestart.pid); + await paseo(["daemon", "restart", "--home", paseoHome, "--kill-terminals"]); + await expectClosed(killedByRestart.pid, "restart --kill-terminals"); + assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart"); + console.log("✓ closed\n"); + + console.log("Test 5: daemon stop closes the terminal and its worker"); + const killedByStop = await openTerminal("stop-kill"); + trackedPids.push(killedByStop.pid); + await paseo(["daemon", "stop", "--home", paseoHome]); + await expectClosed(killedByStop.pid, "stop"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + console.log("Test 6: daemon stop --force closes the terminal"); + await paseo(["daemon", "start"]); + const killedByForce = await openTerminal("force-kill"); + trackedPids.push(killedByForce.pid); + await paseo(["daemon", "stop", "--home", paseoHome, "--force"]); + await expectClosed(killedByForce.pid, "stop --force"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + const endpoint = `127.0.0.1:${port}`; + console.log("Test 7: restart --host --kill-terminals closes the terminal"); + await paseo(["daemon", "start"]); + const killedByEndpointRestart = await openTerminal("endpoint-restart-kill"); + trackedPids.push(killedByEndpointRestart.pid); + await paseo(["daemon", "restart", "--host", endpoint, "--kill-terminals"]); + await expectClosed(killedByEndpointRestart.pid, "restart --host --kill-terminals"); + assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart"); + console.log("✓ closed and confirmed by the replacement\n"); + + console.log("Test 8: restart --host --kill-terminals reports a process cleanup could not end"); + const zombie = await startZombie(); + trackedPids.push(zombie.parentPid); + await plantRecordNaming(zombie.zombiePid); + const unconfirmed = await paseo( + ["daemon", "restart", "--host", endpoint, "--kill-terminals"], + false, + ); + assert.notEqual(unconfirmed.exitCode, 0, "the kill restart fails"); + const output = unconfirmed.stdout + unconfirmed.stderr; + assert(output.includes("Terminal cleanup was not confirmed"), `names the failure:\n${output}`); + assert(output.includes(`pid ${zombie.zombiePid}`), `names the surviving pid:\n${output}`); + process.kill(zombie.parentPid, "SIGKILL"); + await expectClosed(zombie.zombiePid, "zombie reaped once its parent exits"); + await paseo(["daemon", "stop", "--home", paseoHome]); + assert.equal(workerRecordCount(), 0, "the next stop retries the cleanup and retires the record"); + console.log("✓ reported, then retried by stop\n"); + + console.log("Test 9: restart --host --kill-terminals reports a worker record it cannot read"); + await paseo(["daemon", "start"]); + // A kill cleanup cannot check what an unreadable record names, and leaves nothing else + // behind, so the replacement daemon must still report it. + const unreadablePath = join(paseoHome, "terminal-worker", `${await goneProcessPid()}.json`); + writeFileSync(unreadablePath, "{ not json", { mode: 0o600 }); + const unreadable = await paseo( + ["daemon", "restart", "--host", endpoint, "--kill-terminals"], + false, + ); + assert.notEqual(unreadable.exitCode, 0, "the kill restart fails"); + const unreadableOutput = unreadable.stdout + unreadable.stderr; + assert(unreadableOutput.includes(unreadablePath), `names the record:\n${unreadableOutput}`); + assert(existsSync(unreadablePath), "the unreadable record is kept"); + rmSync(unreadablePath); + await paseo(["daemon", "stop", "--home", paseoHome]); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ reported with the record's path\n"); +} finally { + await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"], env); + for (const pid of trackedPids) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + await rm(paseoHome, { recursive: true, force: true }); +} + +console.log("=== Daemon terminals survive restart passed ==="); diff --git a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts new file mode 100644 index 0000000000..ff8ef6bc6f --- /dev/null +++ b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts @@ -0,0 +1,527 @@ +#!/usr/bin/env npx tsx + +/** + * A terminal worker survives an upgrade of the installation that started it. Two disposable + * installations A and B (built dist, real copies of @getpaseo/server, @getpaseo/cli and + * node-pty, every other dependency a symlink into this repo) each get their own CLI shim, so + * each daemon's PASEO_CLI is a distinct path: + * + * 1. A daemon from A runs its worker from a copy in $PASEO_HOME/terminal-worker/. After + * `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away, + * a daemon from B adopts the same worker and opens a new, working terminal in it with B's + * environment and CLI, while the first terminal keeps A's. + * 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): a worker one version + * old drains (keeps its terminal, new ones open in a second worker, it exits after its last + * terminal and its copy is deleted); one two versions old is shut down; one from a newer + * Paseo is left running, reported as terminal_worker_incompatible, and closed by + * `daemon stop`. + * + * Every process it starts is stopped in `finally`. + */ + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getAvailablePort } from "./helpers/network.ts"; + +if (process.platform === "win32") { + console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals)."); + process.exit(0); +} + +console.log("=== Terminal worker survives an upgrade ===\n"); + +const repoRoot = join(import.meta.dirname, "..", "..", ".."); +const root = await mkdtemp(join(tmpdir(), "paseo-worker-upgrade-")); +const paseoHome = join(root, "home"); +const cliHome = join(root, "os-home"); +const workDir = join(root, "work"); +for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); +const port = await getAvailablePort(); +const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null; + +interface Installation { + name: string; + dir: string; + version: string; + shim: string; + serverDir: string; +} + +// --------------------------------------------------------------------------------------------- +// Installation fixtures +// --------------------------------------------------------------------------------------------- + +function linkEntries(source: string, target: string, taken: Set): void { + if (!existsSync(source)) return; + for (const name of readdirSync(source)) { + if (name.startsWith(".")) continue; + if (name.startsWith("@")) { + mkdirSync(join(target, name), { recursive: true }); + for (const scoped of readdirSync(join(source, name))) { + const key = `${name}/${scoped}`; + if (taken.has(key)) continue; + taken.add(key); + symlinkSync(join(source, name, scoped), join(target, name, scoped)); + } + continue; + } + if (taken.has(name)) continue; + taken.add(name); + symlinkSync(join(source, name), join(target, name)); + } +} + +function createInstallation(name: string, version: string): Installation { + const dir = join(root, `install-${name}`); + const modules = join(dir, "node_modules"); + mkdirSync(join(modules, "@getpaseo"), { recursive: true }); + + // Real copies: the server (its version marks the installation), the CLI (so it resolves this + // installation's server), and node-pty (so moving it away breaks only this installation). + const serverDir = join(modules, "@getpaseo", "server"); + const serverPackage = JSON.parse( + readFileSync(join(repoRoot, "packages/server/package.json"), "utf8"), + ) as Record; + mkdirSync(join(serverDir, "dist"), { recursive: true }); + writeFileSync( + join(serverDir, "package.json"), + `${JSON.stringify({ ...serverPackage, version }, null, 2)}\n`, + ); + for (const part of ["server", "src", "scripts"]) { + cpSync(join(repoRoot, "packages/server/dist", part), join(serverDir, "dist", part), { + recursive: true, + }); + } + const cliDir = join(modules, "@getpaseo", "cli"); + mkdirSync(cliDir, { recursive: true }); + cpSync(join(repoRoot, "packages/cli/package.json"), join(cliDir, "package.json")); + cpSync(join(repoRoot, "packages/cli/dist"), join(cliDir, "dist"), { recursive: true }); + cpSync(join(repoRoot, "packages/server/node_modules/node-pty"), join(modules, "node-pty"), { + recursive: true, + }); + + const taken = new Set(["@getpaseo/server", "@getpaseo/cli", "node-pty"]); + for (const source of [ + "packages/server/node_modules", + "packages/cli/node_modules", + "node_modules", + ]) { + linkEntries(join(repoRoot, source), modules, taken); + } + + const shim = join(dir, "bin", "paseo"); + mkdirSync(join(dir, "bin")); + writeFileSync( + shim, + `#!/bin/sh\nexec "${process.execPath}" "${join(cliDir, "dist", "index.js")}" "$@"\n`, + ); + chmodSync(shim, 0o755); + return { name, dir, version, shim, serverDir }; +} + +// The runtime files an upgrade replaces: node-pty, the worker bundle, the shell integration. +function moveRuntimeAway(installation: Installation): () => void { + const away = join(installation.dir, "moved-away"); + const moves = [ + join(installation.dir, "node_modules", "node-pty"), + join(installation.serverDir, "dist", "server", "terminal-worker"), + join(installation.serverDir, "dist", "server", "terminal", "shell-integration"), + ].map((from, index) => ({ from, to: join(away, String(index)) })); + mkdirSync(away, { recursive: true }); + for (const move of moves) renameSync(move.from, move.to); + return () => { + for (const move of moves) renameSync(move.to, move.from); + }; +} + +// --------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------- + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | false, + message: () => string | Promise, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await probe(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(await message()); +} + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function run( + installation: Installation, + args: string[], + extraEnv: Record = {}, +): Promise { + const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: cliHome, + USERPROFILE: cliHome, + PASEO_HOME: paseoHome, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + // Each daemon hands its terminals its own installation's CLI. + PASEO_CLI: installation.shim, + PASEO_TEST_INSTALL: installation.name, + SHELL: "/bin/sh", + ...extraEnv, + }; + return new Promise((resolve) => { + execFile(installation.shim, args, { env, timeout: 120_000 }, (error, stdout, stderr) => { + let exitCode = 0; + if (error) exitCode = typeof error.code === "number" ? error.code : 1; + resolve({ exitCode, stdout, stderr }); + }); + }); +} + +async function paseo( + installation: Installation, + args: string[], + extraEnv: Record = {}, +): Promise { + const result = await run(installation, args, extraEnv); + assert.equal( + result.exitCode, + 0, + `paseo ${args.join(" ")} (${installation.name}) failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return result; +} + +async function json(installation: Installation, args: string[]): Promise { + return JSON.parse((await paseo(installation, [...args, "--json"])).stdout) as T; +} + +async function capture(installation: Installation, terminalId: string): Promise { + const { lines } = await json<{ lines: string[] }>(installation, [ + "terminal", + "capture", + terminalId, + ]); + return lines.join("\n"); +} + +async function type(installation: Installation, terminalId: string, line: string) { + await paseo(installation, ["terminal", "send-keys", terminalId, "-l", line]); + await paseo(installation, ["terminal", "send-keys", terminalId, "Enter"]); +} + +async function waitForScreen(installation: Installation, terminalId: string, text: string) { + await waitFor( + async () => (await capture(installation, terminalId)).includes(text), + async () => + `terminal ${terminalId} never showed ${text}:\n${await capture(installation, terminalId)}`, + ); +} + +// Opens a terminal, records its shell's pid, and prints a marker. +async function openTerminal( + installation: Installation, + name: string, +): Promise<{ id: string; pid: number }> { + const terminal = await json<{ id: string }>(installation, [ + "terminal", + "create", + "--cwd", + workDir, + "--name", + name, + ]); + const pidFile = join(workDir, `${name}.pid`); + await type(installation, terminal.id, `echo $$ > '${pidFile}'; echo MARKER-${name}`); + const pid = await waitFor( + () => + existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() + ? Number(readFileSync(pidFile, "utf8")) + : null, + () => `terminal ${name} never wrote its pid`, + ); + await waitForScreen(installation, terminal.id, `MARKER-${name}`); + trackedPids.push(pid); + return { id: terminal.id, pid }; +} + +// The value of `$variable` in the terminal's shell, written to a file (a long path would wrap +// on screen). +async function echoVariable( + installation: Installation, + terminalId: string, + label: string, + variable: string, +): Promise { + const valueFile = join(workDir, `${label}.value`); + await type( + installation, + terminalId, + `printf '%s' "$${variable}" > '${valueFile}.tmp' && mv '${valueFile}.tmp' '${valueFile}'`, + ); + await waitFor( + () => existsSync(valueFile), + async () => + `terminal ${terminalId} never wrote ${label}:\n${await capture(installation, terminalId)}`, + ); + return readFileSync(valueFile, "utf8"); +} + +interface WorkerRecordFile { + pid: number; + protocolVersion: unknown; + runtime?: string | null; + terminals: Array<{ id: string; pid: number }>; +} + +function workerRecords(): WorkerRecordFile[] { + const dir = join(paseoHome, "terminal-worker"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => /^\d+\.json$/.test(name)) + .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")) as WorkerRecordFile); +} + +function runtimeCopies(): string[] { + const dir = join(paseoHome, "terminal-worker"); + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((name) => + existsSync(join(dir, name, "paseo-terminal-worker-runtime.json")), + ); +} + +function commandLineOf(pid: number): Promise { + return new Promise((resolve) => { + execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], (_error, stdout) => + resolve(stdout.trim()), + ); + }); +} + +async function terminalIds(installation: Installation): Promise { + return (await json>(installation, ["terminal", "ls", "--all"])).map( + (terminal) => terminal.id, + ); +} + +async function startDaemon(installation: Installation, extraEnv: Record = {}) { + await paseo(installation, ["daemon", "start"], extraEnv); +} + +async function stopDaemon(installation: Installation, keepTerminals = false) { + await paseo(installation, [ + "daemon", + "stop", + "--home", + paseoHome, + ...(keepTerminals ? ["--keep-terminals"] : []), + ]); +} + +// A terminal in a worker reporting `protocolVersion`, left running by `stop --keep-terminals`. +async function leaveWorkerWithProtocol( + installation: Installation, + protocolVersion: number, + name: string, +): Promise<{ terminal: { id: string; pid: number }; workerPid: number; runtime: string }> { + await startDaemon(installation, { + PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE: String(protocolVersion), + }); + const terminal = await openTerminal(installation, name); + const [worker] = workerRecords(); + assert(worker, "the daemon started a worker"); + assert.equal(worker.protocolVersion, protocolVersion, "the worker reports the override"); + await stopDaemon(installation, true); + return { terminal, workerPid: worker.pid, runtime: worker.runtime! }; +} + +const trackedPids: number[] = []; +const a = createInstallation("A", "0.98.0"); +const b = createInstallation("B", "0.99.0"); +let restoreA: (() => void) | null = null; +let passed = false; + +try { + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ] as const) { + await paseo(a, ["daemon", "config", "set", field, value]); + } + + console.log("Test 1: a daemon from A runs its worker from a copy in the home"); + await startDaemon(a); + const t1 = await openTerminal(a, "t1"); + const [firstWorker] = workerRecords(); + assert(firstWorker, "one worker runs"); + assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/); + const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!); + assert( + (await commandLineOf(firstWorker.pid)).includes(join(copyA, "terminal-worker.mjs")), + "the worker runs the copy's bundle", + ); + assert.equal(await echoVariable(a, t1.id, "CLI1", "PASEO_CLI"), a.shim, "T1 has A's CLI"); + console.log("✓ worker runs from", copyA, "\n"); + + console.log("Test 2: B adopts the worker after A's runtime files are gone"); + await stopDaemon(a, true); + restoreA = moveRuntimeAway(a); + await startDaemon(b, zsh ? { SHELL: zsh } : {}); + assert.deepEqual( + workerRecords().map((record) => record.pid), + [firstWorker.pid], + "B adopted the same worker instead of starting one", + ); + assert((await terminalIds(b)).includes(t1.id), "T1 is listed by B"); + const t2 = await openTerminal(b, "t2"); + assert.equal(await echoVariable(b, t2.id, "INSTALL2", "PASEO_TEST_INSTALL"), "B"); + assert.equal(await echoVariable(b, t2.id, "CLI2", "PASEO_CLI"), b.shim, "T2 has B's CLI"); + if (zsh) { + // Creating a zsh terminal reads Paseo's integration from the worker's copy (A's is gone); + // a missing file would have failed the create. + const zshVersion = await echoVariable(b, t2.id, "ZSH", "ZSH_VERSION"); + assert(zshVersion.length > 0, "T2 runs zsh with Paseo's shell integration"); + } else { + console.log(" (zsh is not installed: the shell-integration check is skipped)"); + } + assert.equal(await echoVariable(b, t1.id, "CLI1AGAIN", "PASEO_CLI"), a.shim, "T1 keeps A's CLI"); + console.log("✓ same worker, T2 uses B's environment, T1 still works\n"); + + console.log("Test 3: daemon stop closes both terminals and deletes the copy"); + await stopDaemon(b); + await waitFor( + () => !isAlive(t1.pid) && !isAlive(t2.pid) && !isAlive(firstWorker.pid), + () => "terminals or worker outlived daemon stop", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + restoreA(); + restoreA = null; + console.log("✓ closed and cleaned up\n"); + + console.log("Test 4: a worker one protocol version old drains"); + const old = await leaveWorkerWithProtocol(a, 0, "t3"); + await startDaemon(b); + const workers = workerRecords(); + assert.equal(workers.length, 2, "the old worker drains beside a new one"); + const current = workers.find((record) => record.pid !== old.workerPid)!; + assert.equal(current.protocolVersion, 1); + assert.match(current.runtime ?? "", /^0\.99\.0-/); + assert((await terminalIds(b)).includes(old.terminal.id), "the old worker's terminal is listed"); + await type(b, old.terminal.id, "echo still-served"); + await waitForScreen(b, old.terminal.id, "\nstill-served"); + const t4 = await openTerminal(b, "t4"); + assert.deepEqual( + workerRecords() + .find((record) => record.pid === current.pid)! + .terminals.map((terminal) => terminal.id), + [t4.id], + "new terminals open in the current worker", + ); + await paseo(b, ["terminal", "kill", old.terminal.id]); + await waitFor( + () => !isAlive(old.workerPid), + () => `the drained worker ${old.workerPid} did not exit`, + ); + await waitFor( + () => !runtimeCopies().includes(old.runtime), + () => `the drained worker's copy ${old.runtime} was not deleted: ${runtimeCopies().join(", ")}`, + ); + assert.deepEqual(runtimeCopies(), [current.runtime], "the current worker's copy stays"); + await type(b, t4.id, "echo after-drain"); + await waitForScreen(b, t4.id, "\nafter-drain"); + await stopDaemon(b); + console.log("✓ drained, exited, copy deleted\n"); + + console.log("Test 5: a worker two protocol versions old is shut down"); + const obsolete = await leaveWorkerWithProtocol(a, -1, "t5"); + await startDaemon(b); + await waitFor( + () => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid), + () => "the obsolete worker or its terminal survived", + ); + assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone"); + assert.equal(workerRecords().length, 1, "one current worker runs"); + await stopDaemon(b); + console.log("✓ shut down\n"); + + console.log("Test 6: a worker from a newer Paseo is left running and reported"); + const newer = await leaveWorkerWithProtocol(a, 2, "t6"); + await startDaemon(b); + const listed = await run(b, ["terminal", "ls", "--all", "--json"]); + assert.notEqual(listed.exitCode, 0, "terminal ls fails"); + assert.match(listed.stdout + listed.stderr, /held by a newer Paseo \(v0\.98\.0/); + const created = await run(b, ["terminal", "create", "--cwd", workDir, "--json"]); + assert.notEqual(created.exitCode, 0, "terminal create fails"); + assert.match(created.stdout + created.stderr, /held by a newer Paseo/); + const status = await paseo(b, ["daemon", "status", "--home", paseoHome]); + assert.match(status.stdout, /terminals: Terminals are held by a newer Paseo/); + assert(isAlive(newer.terminal.pid), "its program keeps running"); + assert.deepEqual( + workerRecords().map((record) => record.pid), + [newer.workerPid], + "no second worker starts", + ); + await stopDaemon(b); + await waitFor( + () => !isAlive(newer.workerPid) && !isAlive(newer.terminal.pid), + () => "daemon stop left the newer worker or its terminal running", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + console.log("✓ reported, untouched, closed by stop\n"); + passed = true; +} finally { + if (!passed) { + for (const log of ["daemon.log", "terminal-worker.log"]) { + const logPath = join(paseoHome, log); + if (existsSync(logPath)) { + console.error(`--- ${log} (last 60 lines) ---`); + console.error(readFileSync(logPath, "utf8").split("\n").slice(-60).join("\n")); + } + } + } + restoreA?.(); + for (const installation of [b, a]) { + await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]); + } + for (const pid of trackedPids) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + await rm(root, { recursive: true, force: true }); +} + +console.log("=== Terminal worker survives an upgrade passed ==="); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index bca3d00301..d4b0f28a39 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -781,6 +781,9 @@ type ShutdownRequestedStatusPayload = z.infer, + target: string, +): void { + const serverInfo = client.getLastServerInfoMessage(); + if (serverInfo?.features?.terminalsSurviveRestart !== true) { + throw new TerminalKeepUnsupportedError(target, serverInfo?.version ?? null); + } +} + function buildFetchAgentsRequest( options: FetchAgentsOptions | undefined, requestId: string, @@ -4071,13 +4104,14 @@ export class DaemonClient { async restartServer( reason?: string, requestId?: string, - options?: { timeout?: number }, + options?: { timeout?: number; killTerminals?: boolean }, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", ...(reason && reason.trim().length > 0 ? { reason } : {}), requestId: resolvedRequestId, + ...(options?.killTerminals ? { killTerminals: true } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -4105,6 +4139,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "shutdown_server_request", requestId: resolvedRequestId, + ...(options?.keepTerminals ? { keepTerminals: true } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, diff --git a/packages/desktop/src/daemon/cli/external.ts b/packages/desktop/src/daemon/cli/external.ts index 6c9deeccb1..4a55adff1c 100644 --- a/packages/desktop/src/daemon/cli/external.ts +++ b/packages/desktop/src/daemon/cli/external.ts @@ -4,6 +4,31 @@ import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js"; import { createNodeEntrypointInvocation } from "../runtime-paths.js"; import { resolveExternalCliEntrypoint } from "./entrypoints.js"; +/** A CLI command that exited non-zero, with the `--json` error it printed when there was one. */ +export class ExternalCliCommandError extends Error { + readonly code: string | null; + readonly error: Record | null; + + constructor(message: string, error: Record | null) { + super(message); + this.name = "ExternalCliCommandError"; + this.error = error; + this.code = typeof error?.code === "string" ? error.code : null; + } +} + +// `--json` commands print `{ "error": { code, message, ... } }` on stderr when they fail. +function parseJsonCliError(stderr: string): Record | null { + try { + const parsed = JSON.parse(stderr) as { error?: unknown }; + return typeof parsed.error === "object" && parsed.error !== null + ? (parsed.error as Record) + : null; + } catch { + return null; + } +} + function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation { return createNodeEntrypointInvocation({ entrypoint: resolveExternalCliEntrypoint(), @@ -85,7 +110,10 @@ export async function runExternalCliJsonCommand(args: string[]): Promise ({ paseoHome: "", @@ -73,7 +79,8 @@ vi.mock("./runtime-paths.js", () => ({ })), })); -vi.mock("./cli/external.js", () => ({ +vi.mock("./cli/external.js", async (importOriginal) => ({ + ...(await importOriginal>()), runExternalCliJsonCommand: mocks.runExternalCliJsonCommand, runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); @@ -176,3 +183,107 @@ describe("daemon-manager commands", () => { expect(await handler({ listen: "localhost:6799" })).toBeNull(); }); }); + +describe("desktop stops and terminals", () => { + const reasons: DesktopDaemonStopReason[] = [ + "manual_ipc", + "settings", + "host_remove", + "quit", + "app_update", + "version_mismatch", + "restart", + ]; + + it("keeps terminals only for an app update or version-mismatch restart, never on Windows", () => { + for (const platform of ["darwin", "linux"] as const) { + expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, platform))).toEqual([ + "app_update", + "version_mismatch", + ]); + } + expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, "win32"))).toEqual([]); + }); + + function stopHarness(runCliJson: (args: string[]) => Promise) { + const stops: Array<{ keepTerminals: boolean | undefined }> = []; + const logged: string[] = []; + const instance = { + pid: 4242, + startedAt: "2026-10-21T00:00:00.000Z", + hostname: "host", + uid: 501, + listen: "127.0.0.1:6799", + }; + const stopInstance = (async ( + _home: string, + options: { + keepTerminals?: boolean; + requestShutdown?: (ready: { listen: string }) => Promise; + }, + ) => { + stops.push({ keepTerminals: options.keepTerminals }); + await options.requestShutdown?.({ listen: instance.listen }); + return { action: "stopped", pid: instance.pid, forced: false, usedLifecycleRpc: true }; + }) as unknown as NonNullable[0]["stopInstance"]>; + return { + stops, + logged, + input: { + home: "/tmp/paseo-home", + instance, + stopInstance, + runCliJson, + log: (message: string) => logged.push(message), + } as Omit[0], "reason" | "keepTerminals">, + }; + } + + it("asks the daemon to keep its terminals on an app update", async () => { + const calls: string[][] = []; + const harness = stopHarness(async (args) => { + calls.push(args); + return { action: "shutdown_requested" }; + }); + + await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }); + + expect(harness.stops).toEqual([{ keepTerminals: true }]); + expect(calls).toEqual([ + ["daemon", "stop", "--host", "127.0.0.1:6799", "--keep-terminals", "--json"], + ]); + }); + + it("stops a daemon without the capability with its terminals closed, and says so", async () => { + const calls: string[][] = []; + const harness = stopHarness(async (args) => { + calls.push(args); + if (args.includes("--keep-terminals")) { + throw new ExternalCliCommandError("refused", { + code: "TERMINALS_KEEP_UNSUPPORTED", + version: "0.11.0", + }); + } + return { action: "shutdown_requested" }; + }); + + await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }); + + expect(harness.stops).toEqual([{ keepTerminals: true }, { keepTerminals: false }]); + expect(calls[1]).toEqual(["daemon", "stop", "--host", "127.0.0.1:6799", "--json"]); + expect(harness.logged).toContain( + "daemon v0.11.0 cannot keep terminals; stopping it and closing them", + ); + }); + + it("does not retry a keep stop that failed for another reason", async () => { + const harness = stopHarness(async () => { + throw new ExternalCliCommandError("unreachable", { code: "DAEMON_UNREACHABLE" }); + }); + + await expect( + stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }), + ).rejects.toThrow("unreachable"); + expect(harness.stops).toEqual([{ keepTerminals: true }]); + }); +}); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 5f3f5394c6..769b5513f5 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -37,7 +37,11 @@ import { closeLocalTransportSession, } from "./local-transport.js"; import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; -import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js"; +import { + ExternalCliCommandError, + runExternalCliJsonCommand, + runExternalCliTextCommand, +} from "./cli/external.js"; import { createDesktopSettingsCommandHandlers, type DesktopCommandHandler, @@ -348,18 +352,84 @@ export async function stopDesktopDaemon( "Daemon changed since confirmation; inspect its current home and PID before stopping it.", ); if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); - logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned }); - await stopDaemonInstance(home, { - instance, - timeoutMs: 15_000, - requestShutdown: async (ready) => { - await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]); - }, + const keepTerminals = keepsTerminalsOnStop(reason, process.platform); + logDesktopDaemonLifecycle("stopping captured supervisor", { + reason, + pid: instance.pid, + owned, + keepTerminals, }); + await stopCapturedDaemon({ home, instance, reason, keepTerminals }); if (owned) ownedLaunch = null; return resolveDesktopDaemonStatus(); } +export interface StopCapturedDaemonInput { + home: string; + instance: DaemonInstance; + reason: DesktopDaemonStopReason; + keepTerminals: boolean; + stopInstance?: typeof stopDaemonInstance; + runCliJson?: typeof runExternalCliJsonCommand; + log?: typeof logDesktopDaemonLifecycle; +} + +/** Stops the supervisor desktop captured, keeping its terminals when asked and supported. */ +export async function stopCapturedDaemon(input: StopCapturedDaemonInput): Promise { + const stopInstance = input.stopInstance ?? stopDaemonInstance; + const runCliJson = input.runCliJson ?? runExternalCliJsonCommand; + const logLifecycle = input.log ?? logDesktopDaemonLifecycle; + const stop = (keep: boolean) => + stopInstance(input.home, { + instance: input.instance, + timeoutMs: 15_000, + keepTerminals: keep, + requestShutdown: async (ready) => { + await runCliJson([ + "daemon", + "stop", + "--host", + ready.listen, + ...(keep ? ["--keep-terminals"] : []), + "--json", + ]); + }, + }); + try { + await stop(input.keepTerminals); + } catch (error) { + // The CLI refuses a keep stop before sending anything when the daemon predates the feature; + // that daemon closes terminals on every stop, as every upgrade did before. + if ( + !input.keepTerminals || + !(error instanceof ExternalCliCommandError) || + error.code !== "TERMINALS_KEEP_UNSUPPORTED" + ) { + throw error; + } + const version = typeof error.error?.version === "string" ? error.error.version : "unknown"; + logLifecycle(`daemon v${version} cannot keep terminals; stopping it and closing them`, { + reason: input.reason, + pid: input.instance.pid, + }); + await stop(false); + } +} + +/** + * Whether a desktop stop keeps the daemon's terminals running for the daemon that replaces it. + * Only when one comes straight back (an app update, a version-mismatch restart), and never on + * Windows: a surviving terminal worker runs Paseo.exe and would lock the install folder the + * update replaces. Quit, Settings, host removal and manual stops close terminals. + */ +export function keepsTerminalsOnStop( + reason: DesktopDaemonStopReason, + platform: NodeJS.Platform, +): boolean { + if (platform === "win32") return false; + return reason === "app_update" || reason === "version_mismatch"; +} + async function restartDaemon(): Promise { await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); return resolveDesktopDaemonStatus(); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a55ca023ac..15c6f633e0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -52,6 +52,11 @@ "types": "./dist/client/index.d.ts", "source": "./src/client/index.ts", "default": "./dist/client/index.js" + }, + "./internal/lifecycle": { + "types": "./dist/server/lifecycle.d.ts", + "source": "./src/server/lifecycle.ts", + "default": "./dist/server/lifecycle.js" } }, "publishConfig": { diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 56f0d0d78c..b0834956d6 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -13,6 +13,7 @@ const entries = { "./server/provider": "server", "./server/usage": "server", "./server/acp": "server", + "./internal/lifecycle": "server", "./client": "client", "./client/host": "client", "./client/ui": "client", @@ -134,12 +135,8 @@ describe("plugin SDK import boundaries", () => { it.each(Object.entries(entries))( "%s respects its %s boundary, including type dependencies", (specifier, runtime) => { - const name = - specifier === "." - ? "index" - : specifier.slice(2) + - (specifier === "./client" || specifier === "./server" ? "/index" : ""); - expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source); + expect(boundaryViolations(file, runtime)).toEqual([]); }, ); }); @@ -170,6 +167,7 @@ describe("plugin example import boundaries", () => { const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; if ( entry === "./client/host" || + entry.startsWith("./internal/") || !(entry in entries) || (entries[entry] !== "shared" && entries[entry] !== runtime) ) @@ -233,7 +231,7 @@ describe("plugin agent tools SDK boundary", () => { }, }); export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { const title: string | null = request.title; // @ts-expect-error labels are read-only request.labels.x = "changed"; @@ -251,6 +249,62 @@ describe("plugin agent tools SDK boundary", () => { }); }); +describe("lifecycle hook SDK types", () => { + it("infers payloads and replacement requests and requires async handlers and new names", () => { + expect( + typeErrors(` + import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server"; + export default function contribute(server: PluginServerContext) { + server.addHook("worktree.create.before", async ({ request }) => { + if (request.kind === "checkout-branch") { + // @ts-expect-error Branch checkout has no branch-off base. + request.baseRef; + } else { + const base: string = request.baseRef; + } + const branch: string = request.branch; + // @ts-expect-error Repository identity is read-only. + request.repoRoot = "/other"; + // @ts-expect-error Source checkout identity is read-only. + request.sourceCheckout = "/other"; + if (request.kind === "checkout-change-request") { + const number: number = request.changeRequestNumber; + } + return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" }; + }); + server.addHook("worktree.create.before", async () => {}); + server.addHook("worktree.create.after", async ({ worktree }) => { + const path: string = worktree.path; + const branch: string = worktree.branch; + const source: string = worktree.sourceCheckout; + // @ts-expect-error Worktree facts have no workspace identity. + worktree.workspaceId; + }); + server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" })); + server.addHook("workspace.create.before", async () => {}); + server.addHook("agent.open.before", async () => {}); + server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => { + const kind: "completed" | "failed" | "canceled" = outcome.kind; + // @ts-expect-error Notification payloads have no creation request. + outcome.request; + }); + // @ts-expect-error Async handlers are required even for notifications. + server.addHook("agent.turn.before", () => {}); + // @ts-expect-error Legacy names are only accepted by deprecated registration methods. + server.addHook("agent.session_open", async () => {}); + // @ts-expect-error Unknown names fail typechecking. + server.addHook("nonsense", async () => {}); + // @ts-expect-error A prepare handler must return the whole request or undefined. + server.addHook("agent.create.before", async () => ({ invalid: true })); + server.before("agent.session_open", ({ request }) => request); + server.on("agent.turn_ended", () => {}); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + describe("agent session reload SDK boundary", () => { it("exposes reload separately from snapshot refresh", () => { expect( diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 264effafdf..bb6bf4ede8 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -13,8 +13,15 @@ export type { PluginSettingsState, } from "./contracts.js"; export type { + PluginHooks, + PluginHookEvents, + PluginHookRequests, + PluginHookPayload, + PluginHookResult, PluginHookContext, PluginHookWorkspace, + PluginHookWorktree, + PluginWorktreeCreateRequest, PluginHookAgent, PluginSessionOpenRequest, PluginTurnOutcome, diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 9486383072..20df9c2dcb 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -21,6 +21,46 @@ export interface PluginHookWorkspace { archivedAt: string | null; } +export interface PluginHookWorktree { + path: string; + branch: string; + sourceCheckout: string; +} + +/** Resolved creation options before Git fetches refs or chooses a unique branch name. */ +export type PluginWorktreeCreateRequest = { + readonly repoRoot: string; + readonly sourceCheckout: string; + branch: string; + slug: string; + path: string; +} & ( + | { kind: "branch-off"; baseRef: string } + | { kind: "checkout-branch" } + | { + kind: "checkout-change-request"; + baseRef: string; + forge: string; + changeRequestNumber: number; + headRef: string; + headRepositoryOwner?: string; + headRepository?: string; + checkoutRefs?: { remoteName?: string; remoteRef: string }[]; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + } + | { + kind: "checkout-github-pr"; + baseRef: string; + githubPrNumber: number; + headRef: string; + headRepositoryOwner?: string; + checkoutRefs?: { remoteName?: string; remoteRef: string }[]; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + } +); + export interface PluginHookAgent { id: string; workspaceId: string | null; @@ -49,9 +89,9 @@ export type PluginTurnOutcome = | { kind: "failed"; error: { message: string; code?: string } } | { kind: "canceled"; reason: string }; -export interface PluginLifecycleEvents { - "agent.turn_started": { agent: PluginHookAgent; turnId: string | null }; - "agent.turn_ended": { +export interface PluginHookEvents { + "agent.turn.before": { agent: PluginHookAgent; turnId: string | null }; + "agent.turn.after": { agent: PluginHookAgent; turnId: string | null; outcome: PluginTurnOutcome; @@ -61,26 +101,58 @@ export interface PluginLifecycleEvents { */ timeline: readonly AgentTimelineItem[]; }; - "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; - "agent.permission_resolved": { + "agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest }; + "agent.permission.after": { agent: PluginHookAgent; requestId: string; resolution: AgentPermissionResponse; }; - "agent.archived": { agent: PluginHookAgent; archivedAt: string }; - "agent.created": { agent: PluginHookAgent }; - "agent.closed": { agent: PluginHookAgent }; - "workspace.created": { workspace: PluginHookWorkspace }; - "workspace.archived": { workspace: PluginHookWorkspace }; + "agent.archive.after": { agent: PluginHookAgent; archivedAt: string }; + "agent.create.after": { agent: PluginHookAgent }; + "agent.close.after": { agent: PluginHookAgent }; + "workspace.create.after": { workspace: PluginHookWorkspace }; + "workspace.archive.after": { workspace: PluginHookWorkspace }; } -export interface PluginBeforeRequests { - "agent.create": { config: AgentSessionConfig; env?: Record }; - "agent.session_open": PluginSessionOpenRequest; - "workspace.create": Omit; +export interface PluginHookRequests { + "worktree.create.before": PluginWorktreeCreateRequest; + "agent.create.before": { config: AgentSessionConfig; env?: Record }; + "agent.open.before": PluginSessionOpenRequest; + "workspace.create.before": Omit; } +export type PluginHooks = { + [Name in keyof PluginHookRequests]: { + request: PluginHookRequests[Name]; + result: PluginHookRequests[Name]; + }; +} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & { + "worktree.create.after": { payload: { worktree: PluginHookWorktree } }; +}; + +export type PluginHookPayload = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request } + ? { request: Request } + : PluginHooks[Key] extends { payload: infer Payload } + ? Payload + : never; +}[Name]; +export type PluginHookResult = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result } + ? Result | void + : void; +}[Name]; + +type HookHandlers = { + [Name in keyof PluginHooks]: ( + payload: PluginHookPayload, + context: PluginHookContext, + ) => Promise>; +}; + export interface PluginLifecycleRegistration { + addHook(name: Name, handler: HookHandlers[Name]): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ on( name: Name, handler: ( @@ -88,6 +160,7 @@ export interface PluginLifecycleRegistration { context: PluginHookContext, ) => void | Promise, ): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ before( name: Name, handler: ( @@ -96,3 +169,69 @@ export interface PluginLifecycleRegistration { ) => PluginBeforeRequests[Name] | void | Promise, ): () => void; } + +/** @internal Lifecycle policy shared by the daemon and plugin process. */ +export const pluginHookRegistry = { + "workspace.create.before": { waits: true }, + "workspace.create.after": { waits: false }, + "workspace.archive.after": { waits: false }, + "worktree.create.before": { waits: true }, + "worktree.create.after": { waits: true }, + "agent.create.before": { waits: true }, + "agent.create.after": { waits: false }, + "agent.open.before": { waits: true }, + "agent.close.after": { waits: false }, + "agent.archive.after": { waits: false }, + "agent.turn.before": { waits: false }, + "agent.turn.after": { waits: false }, + "agent.permission.before": { waits: false }, + "agent.permission.after": { waits: false }, +} as const satisfies Record; + +// COMPAT(pluginHookNames): added in v0.14, remove after 2027-04-30 +const legacyEvents = { + "workspace.created": "workspace.create.after", + "workspace.archived": "workspace.archive.after", + "agent.created": "agent.create.after", + "agent.closed": "agent.close.after", + "agent.archived": "agent.archive.after", + "agent.turn_started": "agent.turn.before", + "agent.turn_ended": "agent.turn.after", + "agent.permission_requested": "agent.permission.before", + "agent.permission_resolved": "agent.permission.after", +} as const; +const legacyRequests = { + "workspace.create": "workspace.create.before", + "agent.create": "agent.create.before", + "agent.session_open": "agent.open.before", +} as const; +export type PluginLifecycleEvents = { + [Name in keyof typeof legacyEvents]: PluginHookEvents[(typeof legacyEvents)[Name]]; +}; +export type PluginBeforeRequests = { + [Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]]; +}; + +/** @internal SDK adapter for the daemon's hook registry. */ +export function createLifecycleRegistration( + register: ( + name: keyof PluginHooks, + handler: (input: never, context: PluginHookContext) => unknown, + ) => () => void, +): PluginLifecycleRegistration { + const names = new Set(Object.keys(pluginHookRegistry)); + return { + addHook(name, handler) { + if (!names.has(name)) throw new Error(`Unknown lifecycle hook: ${name}`); + return register(name, handler); + }, + on(name, handler) { + if (!Object.hasOwn(legacyEvents, name)) throw new Error(`Unknown lifecycle event: ${name}`); + return register(legacyEvents[name], handler); + }, + before(name, handler) { + if (!Object.hasOwn(legacyRequests, name)) throw new Error(`Unknown before hook: ${name}`); + return register(legacyRequests[name], handler); + }, + }; +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 92d6ec71e1..26a90016a7 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1896,11 +1896,17 @@ export const RestartServerRequestMessageSchema = z.object({ type: z.literal("restart_server_request"), reason: z.string().optional(), requestId: z.string(), + // Restart keeps terminals unless this is true. Daemons that predate + // `features.terminalsSurviveRestart` ignore it and close terminals anyway. + killTerminals: z.boolean().optional(), }); export const ShutdownServerRequestMessageSchema = z.object({ type: z.literal("shutdown_server_request"), requestId: z.string(), + // Shutdown closes terminals unless this is true. Gate on + // `features.terminalsSurviveRestart`: older daemons drop the field. + keepTerminals: z.boolean().optional(), }); export const DaemonUpdateRequestMessageSchema = z.object({ @@ -3763,6 +3769,8 @@ export const ServerInfoStatusPayloadSchema = z daemonDiagnostics: z.boolean().optional(), // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: z.boolean().optional(), + // COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. + terminalsSurviveRestart: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. @@ -5125,6 +5133,22 @@ export const DaemonGetStatusResponseSchema = z.object({ error: z.string().nullable().optional(), }), ), + // Processes a terminal cleanup on this host (a kill restart or stop) could not confirm + // gone; empty when every cleanup finished. Daemons without terminalsSurviveRestart have + // no such cleanup and omit it. + terminalCleanupUnconfirmed: z + .array( + z.object({ + pid: z.number(), + role: z.string(), + reason: z.string(), + detail: z.string(), + }), + ) + .optional(), + // Set when this host's terminals are held by a terminal worker from a newer Paseo, which + // this daemon leaves running but cannot drive: why, and what the user can do. + terminalsUnavailable: z.string().optional(), }) .passthrough(), }); diff --git a/packages/server/package.json b/packages/server/package.json index c651f134bf..5572271cbb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -73,7 +73,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && 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'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node scripts/build-terminal-worker.mjs && 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'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "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:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/scripts/build-terminal-worker.mjs b/packages/server/scripts/build-terminal-worker.mjs new file mode 100644 index 0000000000..3a5b290fc3 --- /dev/null +++ b/packages/server/scripts/build-terminal-worker.mjs @@ -0,0 +1,55 @@ +// Bundles the terminal worker into one file with its shell integration beside it. The daemon +// copies this directory (plus node-pty) into $PASEO_HOME/terminal-worker/ and launches the worker +// from the copy, so a worker that outlives an upgrade never loads files from the installation +// that replaced the one it came from. See docs/terminal-worker.md. +// +// Usage: node scripts/build-terminal-worker.mjs [--outdir ] +// Default output: dist/server/terminal-worker/ + +import { cp, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + +export const TERMINAL_WORKER_BUNDLE_NAME = "terminal-worker.mjs"; + +export async function buildTerminalWorkerBundle({ outDir }) { + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + await build({ + entryPoints: [path.join(packageRoot, "src/terminal/terminal-worker-process.ts")], + bundle: true, + format: "esm", + platform: "node", + target: "node20", + // Loaded from the copy's own node_modules/ at run time. + external: ["node-pty"], + // Bundled CommonJS dependencies call require() for Node built-ins. + banner: { + js: 'import { createRequire as __terminalWorkerCreateRequire } from "node:module"; const require = __terminalWorkerCreateRequire(import.meta.url);', + }, + outfile: path.join(outDir, TERMINAL_WORKER_BUNDLE_NAME), + logLevel: "warning", + }); + // terminal.ts resolves the zsh integration relative to its own module, which is the bundle. + await cp( + path.join(packageRoot, "src/terminal/shell-integration"), + path.join(outDir, "shell-integration"), + { recursive: true }, + ); +} + +function readOutDir(argv) { + const index = argv.indexOf("--outdir"); + if (index === -1) return path.join(packageRoot, "dist/server/terminal-worker"); + const value = argv[index + 1]; + if (!value) throw new Error("--outdir requires a directory"); + return path.resolve(value); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await buildTerminalWorkerBundle({ outDir: readOutDir(process.argv.slice(2)) }); +} diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index ee3746accf..7d0e7f50aa 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -12,6 +12,7 @@ import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { daemonLogPath } from "../src/server/daemon-instance.js"; import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; +import { reapTerminalWorkers } from "../src/terminal/terminal-worker-reaper.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; @@ -175,6 +176,10 @@ async function main(): Promise { onWorkerExit: () => updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, + cleanupTerminals: async (log) => { + const result = await reapTerminalWorkers({ paseoHome, log }); + log("Terminal worker cleanup result", { ...result }); + }, }); requestSupervisorShutdown = supervisor.requestShutdown; } diff --git a/packages/server/scripts/supervisor.terminal-cleanup.test.ts b/packages/server/scripts/supervisor.terminal-cleanup.test.ts new file mode 100644 index 0000000000..224a0788d3 --- /dev/null +++ b/packages/server/scripts/supervisor.terminal-cleanup.test.ts @@ -0,0 +1,180 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +// A real supervisor with scripted workers. Each worker generation logs what it sees to an +// events file; `cleanupTerminals` logs "cleanup". The order of events is the contract: a kill +// intent cleans up before the next worker starts, a keep intent or a crash never cleans up. + +async function runSupervisorWith(workerScript: string, timeoutMs = 8_000) { + const home = await mkdtemp(path.join(tmpdir(), "paseo-terminal-cleanup-")); + const workerPath = path.join(home, "worker.mjs"); + const runnerPath = path.join(home, "runner.mjs"); + const eventsPath = path.join(home, "events.jsonl"); + const supervisor = new URL("./supervisor.ts", import.meta.url).href; + await writeFile( + workerPath, + ` + import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; + const counter = ${JSON.stringify(path.join(home, "generation"))}; + const generation = existsSync(counter) ? Number(readFileSync(counter, "utf8")) + 1 : 1; + writeFileSync(counter, String(generation)); + const record = (event) => + appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n"); + record({ started: generation }); + ${workerScript} + `, + ); + await writeFile( + runnerPath, + ` + import { appendFileSync } from "node:fs"; + import { runSupervisor } from ${JSON.stringify(supervisor)}; + runSupervisor({ + name: "TerminalCleanupTest", startupMessage: "starting", restartOnCrash: true, + resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [], + cleanupTerminals: async () => { + appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify("cleanup") + "\\n"); + }, + }); + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { ...env, HOME: home, PASEO_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + try { + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(output)); + }, timeoutMs); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + const events = (await readFile(eventsPath, "utf8").catch(() => "")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as unknown); + return { code, output, events }; + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +// Generation 1 asks for `firstIntent`; generation 2 (if any) stops with `secondIntent`. +function scriptedWorker(firstIntent: string, secondIntent = "") { + return ` + process.on("message", (message) => { + if (message.type !== "paseo:graceful-shutdown") return; + record({ graceful: generation, keepTerminals: message.keepTerminals }); + process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" }); + if (generation === 1) { ${firstIntent} } + else { ${secondIntent} } + `; +} + +const stopKeeping = `process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: true });`; + +describe("supervisor terminal cleanup", () => { + test("a keep restart passes keepTerminals and never cleans up", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:restart", reason: "test", keepTerminals: true });`, + stopKeeping, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: true }, + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill restart cleans up after the worker exits and before the respawn", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:restart", reason: "test", keepTerminals: false });`, + stopKeeping, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill restart whose worker hangs cleans up after the forced kill, before the respawn", async () => { + const result = await runSupervisorWith( + ` + process.on("message", (message) => { + if (message.type !== "paseo:graceful-shutdown") return; + record({ graceful: generation, keepTerminals: message.keepTerminals }); + if (generation === 2) process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" }); + if (generation === 1) { + process.send({ type: "paseo:restart", reason: "test", keepTerminals: false }); + setInterval(() => {}, 1000); + } else { + ${stopKeeping} + } + `, + 20_000, + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }, 30_000); + + test("a crash restart keeps terminals", async () => { + const result = await runSupervisorWith(scriptedWorker(`process.exit(1);`, stopKeeping)); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill stop cleans up when the supervisor exits", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: false });`, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + ]); + }); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index ec56822619..fac28946d5 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -21,6 +21,7 @@ type WorkerLifecycleMessage = | { type: "paseo:shutdown"; reason?: string; + keepTerminals: boolean; } | { type: "paseo:ready"; @@ -30,6 +31,7 @@ type WorkerLifecycleMessage = | { type: "paseo:restart"; reason?: string; + keepTerminals: boolean; }; interface SupervisorHeartbeatMessage { @@ -39,8 +41,11 @@ interface SupervisorHeartbeatMessage { interface SupervisorGracefulShutdownMessage { type: "paseo:graceful-shutdown"; reason: string; + keepTerminals: boolean; } +export type SupervisorLifecycleLog = (message: string, fields?: Record) => void; + interface SupervisorOptions { name: string; startupMessage: string; @@ -57,11 +62,15 @@ interface SupervisorOptions { onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; + // Closes every terminal the worker left. Runs after a kill-intent restart or stop once the + // worker has exited (also after a forced kill), before a respawn, and on any supervisor + // exit except a keep-terminals stop. A crash restart keeps terminals and skips it. + cleanupTerminals?: (log: SupervisorLifecycleLog) => Promise; logFile?: SupervisorLogFileOptions; } export interface SupervisorController { - requestShutdown(reason: string): void; + requestShutdown(reason: string, options?: { keepTerminals?: boolean }): void; } function describeExit(code: number | null, signal: NodeJS.Signals | null): string { @@ -74,10 +83,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { } const type = (msg as { type?: unknown }).type; if (type === "paseo:shutdown") { - const reason = (msg as { reason?: unknown }).reason; + const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; return { type: "paseo:shutdown", ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), + keepTerminals: keepTerminals === true, }; } if (type === "paseo:ready") { @@ -91,10 +101,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { return { type: "paseo:ready", listen, serverId }; } if (type === "paseo:restart") { - const reason = (msg as { reason?: unknown }).reason; + const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; return { type: "paseo:restart", ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), + keepTerminals: keepTerminals === true, }; } return null; @@ -185,6 +196,9 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController let child: ChildProcess | null = null; let restarting = false; let shuttingDown = false; + // The resolved terminal decision of the pending restart or stop. It survives the force-kill + // path, so a hung kill restart still closes terminals before the respawn. + let pendingKeepTerminals = false; let exiting = false; let lifecycleFailed = false; let publication = Promise.resolve(); @@ -217,12 +231,31 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController const closeLogStream = (): Promise => durableLog.close(); - const exitSupervisor = (code: number): void => { + const runTerminalCleanup = async (when: string): Promise => { + if (!options.cleanupTerminals) { + return; + } + writeLifecycleLog("Closing terminals", { when }); + try { + await options.cleanupTerminals(writeLifecycleLog); + writeLifecycleLog("Terminal cleanup finished", { when }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Terminal cleanup not confirmed (${when}): ${message}`); + } + }; + + const exitSupervisor = (code: number, exitOptions: { keepTerminals?: boolean } = {}): void => { if (exiting) { return; } exiting = true; - Promise.resolve(options.onSupervisorExit?.()) + const cleanup = + exitOptions.keepTerminals === true + ? Promise.resolve() + : runTerminalCleanup("supervisor_exit"); + cleanup + .then(() => options.onSupervisorExit?.()) .catch((error) => { const message = error instanceof Error ? error.message : String(error); log(`Supervisor exit cleanup failed: ${message}`); @@ -357,14 +390,20 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController if (lifecycleMessage.type === "paseo:shutdown") { const reason = lifecycleMessage.reason ?? "worker_requested_shutdown"; - writeLifecycleLog("Worker requested shutdown", { reason }); - requestShutdown(reason); + writeLifecycleLog("Worker requested shutdown", { + reason, + keepTerminals: lifecycleMessage.keepTerminals, + }); + requestShutdown(reason, { keepTerminals: lifecycleMessage.keepTerminals }); return; } const reason = lifecycleMessage.reason ?? "worker_requested_restart"; - writeLifecycleLog("Worker requested restart", { reason }); - requestRestart(reason); + writeLifecycleLog("Worker requested restart", { + reason, + keepTerminals: lifecycleMessage.keepTerminals, + }); + requestRestart(reason, lifecycleMessage.keepTerminals); }); child.on("error", (error) => { @@ -395,7 +434,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController if (shuttingDown) { log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); - exitSupervisor(0); + exitSupervisor(0, { keepTerminals: pendingKeepTerminals }); return; } @@ -404,13 +443,25 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); if (restarting || crashed) { + const closeTerminals = restarting && !pendingKeepTerminals; restarting = false; + pendingKeepTerminals = false; log( - crashed + crashed && !closeTerminals ? `Worker crashed (${exitDescriptor}). Restarting worker...` : `Worker exited (${exitDescriptor}). Restarting worker...`, ); - spawnWorker(); + if (!closeTerminals) { + spawnWorker(); + return; + } + // The replacement daemon must never adopt terminals the user asked to close. + void runTerminalCleanup("before_respawn").then(() => { + if (!shuttingDown) { + spawnWorker(); + } + return; + }); return; } @@ -429,9 +480,11 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController const message: SupervisorGracefulShutdownMessage = { type: "paseo:graceful-shutdown", reason, + keepTerminals: pendingKeepTerminals, }; writeLifecycleLog("Supervisor requesting graceful worker shutdown", { reason, + keepTerminals: pendingKeepTerminals, supervisorPid: process.pid, workerPid: currentChild.pid ?? null, }); @@ -455,28 +508,33 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController }); }; - const requestRestart = (reason: string) => { + const requestRestart = (reason: string, keepTerminals: boolean) => { if (!child || restarting || shuttingDown) { return; } restarting = true; - writeLifecycleLog("Restart requested", { reason }); + pendingKeepTerminals = keepTerminals; + writeLifecycleLog("Restart requested", { reason, keepTerminals }); log(`${reason}. Stopping worker for restart...`); requestWorkerShutdown(reason); scheduleForceKill(reason); }; - const requestShutdown = (reason: string) => { + const requestShutdown = (reason: string, shutdownOptions: { keepTerminals?: boolean } = {}) => { if (shuttingDown) { return; } shuttingDown = true; restarting = false; - writeLifecycleLog("Supervisor shutdown requested", { reason }); + pendingKeepTerminals = shutdownOptions.keepTerminals === true; + writeLifecycleLog("Supervisor shutdown requested", { + reason, + keepTerminals: pendingKeepTerminals, + }); log(`${reason}. Stopping worker...`); if (!child) { void publication.then(() => { - exitSupervisor(lifecycleFailed ? 1 : 0); + exitSupervisor(lifecycleFailed ? 1 : 0, { keepTerminals: pendingKeepTerminals }); return; }); return; diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 97bb8af3a3..064dcec64e 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,17 +1,10 @@ -import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { delimiter, resolve } from "node:path"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; +import which from "which"; import { windowsExecutableResolution } from "./windows.js"; -type Which = ( - command: string, - options: { all: true; path?: string; pathExt?: string }, -) => Promise; - -const require = createRequire(import.meta.url); -const which = require("which") as Which; const PROBE_TIMEOUT_MS = 2000; function hasPathSeparator(value: string): boolean { diff --git a/packages/server/src/executable-resolution/which.d.ts b/packages/server/src/executable-resolution/which.d.ts new file mode 100644 index 0000000000..e7fd869f25 --- /dev/null +++ b/packages/server/src/executable-resolution/which.d.ts @@ -0,0 +1,10 @@ +// `which` ships no types. Imported statically (not through createRequire) so the terminal +// worker bundle includes it: the worker runs from a copy that has only node-pty beside it. +// Listed in the tsconfig includes, since nothing imports a declaration file. +declare module "which" { + function which( + command: string, + options: { all: true; path?: string; pathExt?: string }, + ): Promise; + export default which; +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2d21bdc63c..eb1c05b50f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7807,7 +7807,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); expect(await storage.get(agentId)).not.toBeNull(); // Workspace ownership preserves ordinary agent lifecycle hooks. - expect(beforeHookCalls).toContain("agent.create"); + expect(beforeHookCalls).toContain("agent.create.before"); }); test("global subscribers receive internal agent events only when they opt in", async () => { @@ -12072,15 +12072,15 @@ test.each([ const session = client.sessions[0]!; session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" }); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_started").length) + .poll(() => events.filter(({ name }) => name === "agent.turn.before").length) .toBe(1); expect(timelineStore.itemReads).toBe(readsBeforeDispatch); session.pushEvent(terminalEvent); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_ended")) + .poll(() => events.filter(({ name }) => name === "agent.turn.after")) .toEqual([ { - name: "agent.turn_ended", + name: "agent.turn.after", event: { agent: { id: agent.id, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 8cbdfc068e..966542ff62 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1358,7 +1358,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); if (this.pluginLifecycle && !config.internal) { - const request = await this.pluginLifecycle.before("agent.create", { + const request = await this.pluginLifecycle.before("agent.create.before", { config, env: options.env, }); @@ -1402,7 +1402,7 @@ export class AgentManager { historyPrimed: true, }); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.created", { + this.pluginLifecycle?.emit("agent.create.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -2114,7 +2114,7 @@ export class AgentManager { const archivedRecord = buildArchivedAgentRecord(record, options); await this.requireRegistry().upsert(archivedRecord); if (!record.archivedAt && !record.internal) { - this.pluginLifecycle?.emit("agent.archived", { + this.pluginLifecycle?.emit("agent.archive.after", { agent: describeHookAgent(archivedRecord), archivedAt: archivedRecord.archivedAt, }); @@ -4051,7 +4051,7 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.closed", { + this.pluginLifecycle?.emit("agent.close.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -5552,7 +5552,7 @@ export class AgentManager { tools: [], mcpServers: {}, }; - const transformed = await this.pluginLifecycle.before("agent.session_open", request); + const transformed = await this.pluginLifecycle.before("agent.open.before", request); env = transformed.env; if (request.purpose === "interactive") { this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f2c7da8574..3b72c8805a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -76,7 +76,7 @@ import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.j import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js"; -import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; +import { startWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; @@ -1384,13 +1384,24 @@ describe("terminal MCP tools", () => { ); it.each([ - ["in-process", createTerminalManager], - ["worker", createWorkerTerminalManager], + ["in-process", async () => ({ manager: createTerminalManager(), home: null })], + [ + "worker", + async () => { + const home = await mkdtemp(join(tmpdir(), "mcp-terminal-keys-home-")); + const manager = await startWorkerTerminalManager({ + paseoHome: home, + paseoVersion: "0.0.0-test", + logger, + }); + return { manager, home }; + }, + ], ] as const)( "delivers MCP key tokens through %s after terminal input modes change", async (_name, createManager) => { const { agentManager, agentStorage } = createTestDeps(); - const terminalManager = createManager(); + const { manager: terminalManager, home } = await createManager(); const terminal = await terminalManager.createTerminal({ cwd: process.cwd(), workspaceId: "ws-terminal-keys", @@ -1420,15 +1431,15 @@ describe("terminal MCP tools", () => { logger, }); + // Every step is a round trip through a real PTY. A loaded Windows runner (ConPTY) has + // taken over 3 s to reach READY, so each step gets the same window rather than 1 s. + const roundTrip = { timeout: 5000 }; let output = ""; const unsubscribe = terminal.subscribe((message) => { if (message.type === "output") { output += message.data; } }); - // Every key travels through the PTY and back; on loaded Windows runners one round - // trip can take seconds, so each wait gets the same budget as the first. - const roundTrip = { timeout: 5000 }; try { await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); @@ -1465,6 +1476,7 @@ describe("terminal MCP tools", () => { await server.close(); await terminalManager.killTerminalAndWait(terminal.id); terminalManager.killAll(); + if (home) await rm(home, { recursive: true, force: true }); } }, ); diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index 843ef21ebb..aeb87388fd 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -455,9 +455,15 @@ test.each([ let original: Awaited> | undefined; let resumed: Awaited> | undefined; try { + // Windows invokes the platform binary directly. Installing its wrapper also runs an + // unused postinstall binary probe, which has no timeout and can hold the fixture open. + const installPackage = + process.platform === "win32" + ? `${windowsPackage}-${process.arch}@${cliPackage.slice(cliPackage.lastIndexOf("@") + 1)}` + : cliPackage; await execCommand( "npm", - ["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage], + ["install", "--prefix", root, "--no-audit", "--no-fund", installPackage], { cwd: root, timeout: 180_000, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 83ef2acb83..36ba9e2b06 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -180,7 +180,7 @@ import { import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; +import { startWorkerTerminalManager } from "../terminal/worker-terminal-manager.js"; import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; @@ -396,12 +396,14 @@ export type DaemonLifecycleIntent = clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } | { type: "restart"; clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }; export interface PaseoDaemonConfig { @@ -493,7 +495,8 @@ export interface PaseoDaemon { scriptRuntimeStore: WorkspaceScriptRuntimeStore; browserToolsBroker: BrowserToolsBroker; start(): Promise; - stop(): Promise; + /** Stops the daemon. Terminals keep running for the next daemon only with keepTerminals. */ + stop(options?: { keepTerminals?: boolean }): Promise; getListenTarget(): ListenTarget | null; getServerId(): string; } @@ -709,7 +712,10 @@ export async function createPaseoDaemon( }); let boundListenTarget: ListenTarget | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; - const terminalManager = createConfiguredTerminalManager({ + const terminalManager = await startWorkerTerminalManager({ + paseoHome: config.paseoHome, + paseoVersion: daemonVersion, + logger, getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), }); applyTerminalAgentHookSetting({ store: daemonConfigStore, logger }); @@ -957,7 +963,7 @@ export async function createPaseoDaemon( if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); } if (mutation.kind === "archive" && mutation.workspace) { - pluginRuntime.emit("workspace.archived", { + pluginRuntime.emit("workspace.archive.after", { workspace: describeHookWorkspace(mutation.workspace), }); } @@ -1206,6 +1212,7 @@ export async function createPaseoDaemon( worktreesRoot: config.worktreesRoot, createPaseoWorktree: async (workflowInput, workflowOptions) => { return createRegisteredPaseoWorktree(workflowInput, { + lifecycle: pluginRuntime, github, ...(workflowOptions?.resolveDefaultBranch ? { @@ -1935,7 +1942,9 @@ export async function createPaseoDaemon( } }; - const stop = async () => { + const stop = async (options?: { keepTerminals?: boolean }) => { + const keepTerminals = options?.keepTerminals === true; + let terminalShutdownError: unknown = null; localCredential = null; await deleteLocalCredential(config.paseoHome); // Stop tracking plugin provider registrations before anything tears plugins @@ -1965,7 +1974,19 @@ export async function createPaseoDaemon( await agentStorage.flush().catch(() => undefined); await agentProviderRuntime.shutdown(); await pluginRuntime.stopAllPlugins(); - terminalManager.killAll(); + try { + if (keepTerminals) { + await terminalManager.detach(); + logger.info("Terminals left running for the next daemon"); + } else { + await terminalManager.closeTerminals(); + } + } catch (error) { + // Finish the rest of the shutdown, then report it: the supervisor and `daemon stop` + // retry the cleanup from the record the reaper kept. + terminalShutdownError = error; + logger.error({ err: error }, "Terminal shutdown failed"); + } await speechService.stop(); await scheduleService.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined); @@ -1988,6 +2009,9 @@ export async function createPaseoDaemon( if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } + if (terminalShutdownError) { + throw terminalShutdownError; + } }; return { diff --git a/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts new file mode 100644 index 0000000000..d606b4d4d9 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts @@ -0,0 +1,688 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; +import { readDaemonInstance, stopDaemonInstance } from "../daemon-instance.js"; +import { currentBootId } from "../pid-lock.js"; +import { connectTerminalWorker } from "../../terminal/terminal-worker-client.js"; +import { createTerminalWorkerProcessInspector } from "../../terminal/terminal-worker-processes.js"; +import { reapTerminalWorkers } from "../../terminal/terminal-worker-reaper.js"; +import { launchTerminalWorker } from "../../terminal/terminal-worker-launch.js"; +import { prepareTerminalWorkerRuntime } from "../../terminal/terminal-worker-runtime.js"; +import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; +import { + listTerminalWorkerRecords, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "../../terminal/terminal-worker-record.js"; + +// Real supervisors started through scripts/supervisor-entrypoint.ts, driven by DaemonClient the +// way the app drives them. Every daemon, worker and program here lives in a throwaway home and +// is killed in afterEach; nothing touches another daemon. + +const SERVER_ROOT = path.resolve(import.meta.dirname, "../../.."); +const SUPERVISOR_ENTRY = path.join(SERVER_ROOT, "scripts/supervisor-entrypoint.ts"); +const READY_TIMEOUT_MS = 90_000; +const POSIX = process.platform !== "win32"; +const inspector = createTerminalWorkerProcessInspector(); + +interface Harness { + home: string; + workDir: string; + port: number; + supervisor: ChildProcess; + output: () => string; +} + +const harnesses: Harness[] = []; +const extraPids = new Set(); +const extraDirs: string[] = []; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | undefined | false, + message: string, + timeoutMs = READY_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await delay(100); + } + throw new Error(`${message}${lastError ? ` (last error: ${String(lastError)})` : ""}`); +} + +async function getAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (!address || typeof address === "string") throw new Error("no port"); + return address.port; +} + +async function startSupervisor( + options: { home?: string; port?: number; pathPrefix?: string } = {}, +): Promise { + const home = options.home ?? mkdtempSync(path.join(tmpdir(), "tw-life-")); + const workDir = path.join(home, "work"); + mkdirSync(workDir, { recursive: true }); + const port = options.port ?? (await getAvailablePort()); + const env: NodeJS.ProcessEnv = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: home, + USERPROFILE: home, + PASEO_HOME: home, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + PASEO_NODE_INSPECT: "0", + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + CI: "true", + }; + if (options.pathPrefix) { + env.PATH = `${options.pathPrefix}${path.delimiter}${env.PATH ?? ""}`; + } + let output = ""; + const supervisor = spawn( + process.execPath, + ["--import", "tsx", SUPERVISOR_ENTRY, "--dev", "--no-relay"], + { cwd: SERVER_ROOT, env, stdio: ["ignore", "pipe", "pipe"] }, + ); + supervisor.stdout?.on("data", (chunk) => (output = (output + String(chunk)).slice(-20_000))); + supervisor.stderr?.on("data", (chunk) => (output = (output + String(chunk)).slice(-20_000))); + const harness: Harness = { home, workDir, port, supervisor, output: () => output }; + harnesses.push(harness); + await waitFor(async () => { + const instance = await readDaemonInstance(home); + return instance?.pid === supervisor.pid && instance.listen ? instance : null; + }, `supervisor did not become ready:\n${output}`); + return harness; +} + +async function connect(harness: Harness): Promise { + return waitFor(async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${harness.port}/ws`, + connectTimeoutMs: 5_000, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } + }, "cannot connect to the daemon"); +} + +// A plain `paseo daemon stop`: SIGTERM on POSIX, the shutdown RPC where there is no signal +// for it (Windows refuses a graceful stop without one). +async function stopDaemon(harness: Harness): Promise { + await stopDaemonInstance(harness.home, { + requestShutdown: async () => { + const client = await connect(harness); + try { + await client.shutdownServer(); + } finally { + await client.close(); + } + }, + }); +} + +async function daemonWorkerPid(client: DaemonClient): Promise { + return (await client.getDaemonStatus()).pid; +} + +async function waitForReplacement(harness: Harness, previousPid: number): Promise { + return waitFor(async () => { + const client = await connect(harness); + const pid = await daemonWorkerPid(client).catch(() => previousPid); + if (pid !== previousPid) return client; + await client.close(); + return null; + }, "the daemon worker was not replaced"); +} + +async function createWorkspace(client: DaemonClient, harness: Harness): Promise { + const result = await client.createWorkspace({ + source: { kind: "directory", path: harness.workDir }, + title: "lifecycle", + }); + if (!result.workspace) throw new Error(result.error ?? "workspace creation failed"); + return result.workspace.id; +} + +interface MarkedTerminal { + id: string; + programPid: number; +} + +// A terminal whose program writes its pid, prints a marker and echoes each input line. +async function createMarkedTerminal( + client: DaemonClient, + harness: Harness, + workspaceId: string, + marker: string, +): Promise { + const pidFile = path.join(harness.workDir, `${marker}.pid`); + const created = await client.createTerminal(harness.workDir, marker, undefined, { + workspaceId, + command: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); + console.log(${JSON.stringify(marker)}); + process.stdin.on("data", (data) => console.log("ECHO:" + data.toString().trim()));`, + ], + }); + if (!created.terminal) throw new Error(created.error ?? "terminal creation failed"); + const programPid = await waitFor( + () => (existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : null), + "terminal program did not start", + 10_000, + ); + await waitForScreen(client, created.terminal.id, marker); + return { id: created.terminal.id, programPid }; +} + +// A shell program that ignores SIGHUP. With `exec`, the shell becomes `sleep`: same pid and +// birth time, another command line. Without it, `sleep` runs as the shell's child. +async function createHupResistantTerminal( + client: DaemonClient, + harness: Harness, + workspaceId: string, + name: string, + mode: "exec" | "child", +): Promise<{ id: string; shellPid: number; sleepPid: number }> { + const shellPidFile = path.join(harness.workDir, `${name}.shell.pid`); + const sleepPidFile = path.join(harness.workDir, `${name}.sleep.pid`); + const script = + mode === "exec" + ? `trap '' HUP; echo $$ > '${shellPidFile}'; echo $$ > '${sleepPidFile}'; exec sleep 100000` + : `trap '' HUP; echo $$ > '${shellPidFile}'; sleep 100000 & echo $! > '${sleepPidFile}'; wait`; + const created = await client.createTerminal(harness.workDir, name, undefined, { + workspaceId, + command: "/bin/sh", + args: ["-c", script], + }); + if (!created.terminal) throw new Error(created.error ?? "terminal creation failed"); + const read = (file: string) => + existsSync(file) && readFileSync(file, "utf8").trim() + ? Number(readFileSync(file, "utf8").trim()) + : null; + const shellPid = await waitFor(() => read(shellPidFile), "shell did not start", 10_000); + const sleepPid = await waitFor(() => read(sleepPidFile), "sleep did not start", 10_000); + extraPids.add(shellPid); + extraPids.add(sleepPid); + // `exec` replaces the shell; wait until the process really is `sleep`. + await waitFor( + async () => { + const inspection = await inspector.inspect(sleepPid); + return inspection.status === "alive" && inspection.snapshot.commandLine?.startsWith("sleep"); + }, + "sleep did not start", + 10_000, + ); + return { id: created.terminal.id, shellPid, sleepPid }; +} + +async function screen(client: DaemonClient, terminalId: string): Promise { + return (await client.captureTerminal(terminalId, { stripAnsi: true })).lines.join("\n"); +} + +async function waitForScreen(client: DaemonClient, terminalId: string, text: string) { + await waitFor( + async () => (await screen(client, terminalId)).includes(text), + `terminal ${terminalId} never showed ${text}`, + 15_000, + ); +} + +async function expectKept( + client: DaemonClient, + terminal: MarkedTerminal, + marker: string, + trigger: string, +) { + const listed = await client.listTerminals(undefined, undefined, {}); + expect(listed.terminals.map((entry) => entry.id)).toContain(terminal.id); + expect(isAlive(terminal.programPid)).toBe(true); + expect(await screen(client, terminal.id)).toContain(marker); + client.sendTerminalInput(terminal.id, { type: "input", data: `after-${trigger}\r` }); + await waitForScreen(client, terminal.id, `ECHO:after-${trigger}`); +} + +async function waitUntilGone(pid: number, what: string) { + await waitFor(() => !isAlive(pid), `${what} (pid ${pid}) is still alive`, 15_000); +} + +async function liveRecords(home: string): Promise { + return (await listTerminalWorkerRecords(home)).flatMap((file) => + "record" in file ? [file.record] : [], + ); +} + +async function currentWorkerRecord(home: string): Promise { + const records = await liveRecords(home); + if (records.length !== 1) throw new Error(`expected one worker record, found ${records.length}`); + return records[0]!; +} + +async function expectAllClosed(home: string, pids: number[]) { + for (const pid of pids) { + await waitUntilGone(pid, "a process that should be closed"); + } + expect(await liveRecords(home)).toEqual([]); +} + +function spawnUnrelated(): ChildProcess { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + extraPids.add(child.pid!); + return child; +} + +async function birthOf(pid: number): Promise { + const inspection = await inspector.inspect(pid); + if (inspection.status !== "alive" || !inspection.snapshot.startedAt) { + throw new Error(`no birth time for ${pid}`); + } + return inspection.snapshot.startedAt; +} + +afterEach(async () => { + for (const harness of harnesses.splice(0)) { + const pid = harness.supervisor.pid; + if (pid && isAlive(pid)) { + try { + process.kill(pid, "SIGCONT"); + } catch { + // gone + } + } + await stopDaemonInstance(harness.home, { force: true, timeoutMs: 15_000 }).catch( + () => undefined, + ); + if (pid && isAlive(pid)) process.kill(pid, "SIGKILL"); + for (const record of await liveRecords(harness.home)) { + if (isAlive(record.pid)) { + try { + process.kill(record.pid, "SIGCONT"); + } catch { + // gone + } + } + } + await reapTerminalWorkers({ paseoHome: harness.home, gracefulMs: 0 }).catch(() => undefined); + rmSync(harness.home, { recursive: true, force: true }); + } + for (const pid of extraPids) { + if (isAlive(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // gone + } + } + } + extraPids.clear(); + for (const dir of extraDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("terminals across daemon restarts", { timeout: 240_000 }, () => { + test("Settings restart keeps the terminal; Settings restart and close terminals kills it", async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-SETTINGS"); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + + // Exactly what the app sends from Settings: no killTerminals. + let daemonPid = await daemonWorkerPid(client); + await client.restartServer("settings_update"); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-SETTINGS", "settings-restart"); + expect((await currentWorkerRecord(harness.home)).pid).toBe(workerPid); + + daemonPid = await daemonWorkerPid(client); + await client.restartServer("settings_update", undefined, { killTerminals: true }); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await waitUntilGone(terminal.programPid, "terminal program"); + await waitUntilGone(workerPid, "old terminal worker"); + expect((await client.listTerminals(undefined, undefined, {})).terminals).toEqual([]); + expect((await liveRecords(harness.home)).every((record) => record.pid !== workerPid)).toBe( + true, + ); + await client.close(); + }); + + test("a daemon crash keeps the terminal; the restarted daemon lists it", async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-CRASH"); + const daemonPid = await daemonWorkerPid(client); + process.kill(daemonPid, "SIGKILL"); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-CRASH", "crash"); + await client.close(); + }); + + test("daemon.update.request restarts the daemon and keeps the terminal", async () => { + // Fake npm only: `ls` reports this checkout as the global install at the daemon's + // version, `install -g` does nothing, and anything else fails so real npm never runs. + const fakeBin = mkdtempSync(path.join(tmpdir(), "tw-npm-")); + extraDirs.push(fakeBin); + const listing = JSON.stringify({ + path: path.join(fakeBin, "global"), + dependencies: { + "@getpaseo/cli": { + version: resolveDaemonVersion(import.meta.url), + path: SERVER_ROOT, + }, + }, + }); + const fakeNpm = path.join(fakeBin, "fake-npm.cjs"); + writeFileSync( + fakeNpm, + `const args = process.argv.slice(2); +const has = (...tokens) => tokens.every((token) => args.includes(token)); +if (has("-g", "ls", "@getpaseo/cli", "--json", "--depth=0", "--long")) { + process.stdout.write(${JSON.stringify(listing)} + "\\n"); +} else if (args[0] === "install" && has("-g", "@getpaseo/cli@latest")) { + // The installed version is already the running one: nothing to do. +} else { + process.stderr.write("unexpected npm call: " + args.join(" ") + "\\n"); + process.exit(1); +} +`, + ); + const npmScript = path.join(fakeBin, "npm"); + writeFileSync(npmScript, `#!/bin/sh\nexec "${process.execPath}" "${fakeNpm}" "$@"\n`); + chmodSync(npmScript, 0o755); + writeFileSync(path.join(fakeBin, "npm.cmd"), `@"${process.execPath}" "${fakeNpm}" %*\r\n`); + const harness = await startSupervisor({ pathPrefix: fakeBin }); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-UPDATE"); + const daemonPid = await daemonWorkerPid(client); + + const update = await client.updateDaemon(); + expect(update).toMatchObject({ success: true }); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-UPDATE", "update"); + await client.close(); + }); + + test.skipIf(!POSIX)( + "POSIX: a kill restart whose shutdown hangs closes terminals before the respawn", + async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-HUNG"); + const daemonPid = await daemonWorkerPid(client); + const supervisorPid = harness.supervisor.pid!; + const logPath = path.join(harness.home, "daemon.log"); + + // Barrier: the supervisor is paused, so the restart intent waits in its IPC channel. + // The daemon's own log line marks the intent dispatched; then the daemon is paused, so + // its graceful shutdown can never run and the supervisor's force path must. + process.kill(supervisorPid, "SIGSTOP"); + await client.restartServer("settings_update", undefined, { killTerminals: true }); + await waitFor( + () => readFileSync(logPath, "utf8").includes("Lifecycle intent dispatched"), + "the daemon never dispatched the restart intent", + 15_000, + ); + process.kill(daemonPid, "SIGSTOP"); + expect(isAlive(terminal.programPid)).toBe(true); + process.kill(supervisorPid, "SIGCONT"); + await client.close(); + + client = await waitForReplacement(harness, daemonPid); + const log = readFileSync(logPath, "utf8"); + const forced = log.indexOf("forcing process tree kill"); + const cleaned = log.indexOf('"msg":"Terminal cleanup finished","when":"before_respawn"'); + const respawned = log.indexOf('"msg":"Spawning worker"', forced); + expect(forced).toBeGreaterThan(-1); + expect(cleaned).toBeGreaterThan(forced); + expect(respawned).toBeGreaterThan(cleaned); + expect(isAlive(terminal.programPid)).toBe(false); + expect((await client.listTerminals(undefined, undefined, {})).terminals).toEqual([]); + await client.close(); + }, + ); +}); + +describe("terminals when the daemon stops", { timeout: 240_000 }, () => { + test.skipIf(!POSIX)( + "POSIX: stop closes a terminal whose program ignores HUP, child process included", + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createHupResistantTerminal( + client, + harness, + workspaceId, + "hup", + "child", + ); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + await client.close(); + await stopDaemon(harness); + await expectAllClosed(harness.home, [terminal.shellPid, terminal.sleepPid, workerPid]); + }, + ); + + for (const force of [false, true]) { + test.skipIf(!POSIX)( + `POSIX: worker death reaps an exec'd HUP-resistant program and ${force ? "stop --force" : "stop"} closes its replacement`, + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createHupResistantTerminal( + client, + harness, + workspaceId, + "orphan", + "exec", + ); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + const daemonPid = await daemonWorkerPid(client); + process.kill(workerPid, "SIGKILL"); + await waitUntilGone(workerPid, "terminal worker"); + await waitUntilGone(terminal.sleepPid, "orphaned terminal program"); + await waitFor( + async () => (await client.listTerminals(undefined, undefined, {})).terminals.length === 0, + "dead terminal was not removed", + ); + expect(await daemonWorkerPid(client)).toBe(daemonPid); + const replacement = await createMarkedTerminal(client, harness, workspaceId, "RECOVERED"); + await expectKept(client, replacement, "RECOVERED", "worker-death"); + await client.close(); + await stopDaemonInstance(harness.home, { force }); + await expectAllClosed(harness.home, [terminal.sleepPid, replacement.programPid]); + }, + ); + } + + test.skipIf(!POSIX)( + "POSIX: stop --force kills a stopped (SIGSTOP) worker and its terminal", + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-STOPPED"); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + await client.close(); + process.kill(workerPid, "SIGSTOP"); + await stopDaemonInstance(harness.home, { force: true }); + await expectAllClosed(harness.home, [workerPid, terminal.programPid]); + }, + ); + + test("the worker socket rejects a wrong secret and leaves its terminals alone", async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-AUTH"); + const record = await currentWorkerRecord(harness.home); + await expect( + connectTerminalWorker({ socketPath: record.socketPath, secret: "not-the-secret" }), + ).rejects.toThrow(); + await expectKept(client, terminal, "MARKER-AUTH", "auth"); + await client.close(); + }); + + test("stop and stop --force never signal processes a stale or earlier-boot record names", async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + await client.close(); + const reused = spawnUnrelated(); + const earlierBoot = spawnUnrelated(); + const base = await currentWorkerRecord(harness.home); + // Same pid, another birth time: what a reused pid looks like, whatever its command line. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "stale-record", + pid: reused.pid!, + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + // The right birth time, but written before this boot. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "earlier-boot-record", + pid: earlierBoot.pid!, + bootId: currentBootId() ? "another-boot" : null, + createdAt: "2000-01-01T00:00:00.000Z", + identity: { + startedAt: await birthOf(earlierBoot.pid!), + commandLine: `node --paseo-terminal-worker earlier-boot-record`, + }, + terminals: [ + { id: "t-boot", pid: earlierBoot.pid!, startedAt: await birthOf(earlierBoot.pid!) }, + ], + }); + + await stopDaemon(harness); + expect(isAlive(reused.pid!)).toBe(true); + expect(isAlive(earlierBoot.pid!)).toBe(true); + expect(await liveRecords(harness.home)).toEqual([]); + + // A second round through --force, with the stale record put back. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "stale-record", + pid: reused.pid!, + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + await stopDaemonInstance(harness.home, { force: true }); + expect(isAlive(reused.pid!)).toBe(true); + expect(await liveRecords(harness.home)).toEqual([]); + }); +}); + +describe("a worker launched from its own copy", { timeout: 240_000 }, () => { + // What a built daemon does: bundle the worker, copy bundle + shell integration + node-pty into + // the home, launch from the copy. Here the copy's node-pty (ConPTY on Windows) opens a real + // terminal; nothing is loaded from this checkout once the worker runs. + test("opens a terminal and runs a command from the copied bundle and node-pty", async () => { + const home = mkdtempSync(path.join(tmpdir(), "tw-copy-home-")); + const bundleDirectory = mkdtempSync(path.join(tmpdir(), "tw-copy-bundle-")); + extraDirs.push(home, bundleDirectory); + const buildScript = path.join(SERVER_ROOT, "scripts/build-terminal-worker.mjs"); + const { buildTerminalWorkerBundle } = (await import(buildScript)) as { + buildTerminalWorkerBundle(input: { outDir: string }): Promise; + }; + await buildTerminalWorkerBundle({ outDir: bundleDirectory }); + const nodePtyDirectory = path.dirname( + createRequire(import.meta.url).resolve("node-pty/package.json"), + ); + const entry = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "0.0.0-copy", + bundleDirectory, + nodePtyDirectory, + }); + + const launched = await launchTerminalWorker({ + paseoHome: home, + paseoVersion: "0.0.0-copy", + entry, + }); + extraPids.add(launched.record.pid); + expect(launched.record.runtime).toBe(entry.runtime); + const manager = createWorkerTerminalManager({ + worker: await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }), + closeTerminals: async () => { + await reapTerminalWorkers({ paseoHome: home }); + }, + }); + try { + const session = await manager.createTerminal({ + workspaceId: "ws-copy", + cwd: home, + command: process.execPath, + args: ["-e", "console.log('COPY-' + 'OK'); setInterval(() => {}, 1000);"], + }); + await waitFor( + async () => + (await manager.captureTerminal(session.id, { stripAnsi: true })).lines + .join("\n") + .includes("COPY-OK"), + "the terminal in the copied worker never printed its marker", + 30_000, + ); + } finally { + await manager.closeTerminals(); + } + expect(isAlive(launched.record.pid)).toBe(false); + expect(existsSync(path.dirname(entry.scriptPath))).toBe(false); + }); +}); diff --git a/packages/server/src/server/daemon-instance.ts b/packages/server/src/server/daemon-instance.ts index 02f4446795..b3b07e7ff0 100644 --- a/packages/server/src/server/daemon-instance.ts +++ b/packages/server/src/server/daemon-instance.ts @@ -11,6 +11,7 @@ import { } from "./pid-lock.js"; import { daemonLaunchEnvironment } from "./config-environment.js"; import { readPersistedConfig } from "./persisted-config.js"; +import { reapTerminalWorkers } from "../terminal/terminal-worker-reaper.js"; import treeKill from "tree-kill"; const killTree = (pid: number, signal: string): Promise => new Promise((resolve, reject) => @@ -29,6 +30,10 @@ export { resolvePaseoHome } from "./paseo-home.js"; export { ensurePrivateDirectory } from "./private-files.js"; export { daemonLaunchEnvironment } from "./config-environment.js"; export { readLocalCredentialForTarget } from "./local-credential.js"; +export { + TerminalCleanupNotConfirmedError, + retryUnconfirmedTerminalCleanup, +} from "../terminal/terminal-worker-reaper.js"; export { isSamePidLock as isSameDaemonInstance, type PidLockInfo as DaemonInstance, @@ -97,11 +102,23 @@ async function requestInstanceStop( instance: PidLockInfo, options: { force?: boolean; + keepTerminals?: boolean; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; }, ) { let forced = false; let usedLifecycleRpc = false; + if (options.keepTerminals) { + // SIGTERM means "stop and close terminals"; keeping them always goes over the RPC. + if (!instance.listen || !options.requestShutdown) { + throw new DaemonInstanceError( + "STOP_NO_GRACEFUL_CHANNEL", + `PID ${instance.pid} for ${home} has no listen address, so it cannot be asked to keep its terminals.`, + ); + } + await options.requestShutdown({ ...instance, listen: instance.listen }); + return { forced, usedLifecycleRpc: true }; + } if (process.platform === "win32") { if (instance.listen && options.requestShutdown) { try { @@ -142,6 +159,8 @@ export async function stopDaemonInstance( options: { instance?: PidLockInfo; force?: boolean; + // Leave the terminal worker running so the next daemon adopts it. Not valid with force. + keepTerminals?: boolean; timeoutMs?: number; killTimeoutMs?: number; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; @@ -153,6 +172,7 @@ export async function stopDaemonInstance( usedLifecycleRpc: boolean; }> { const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; + assertStopOptions(options); const deadline = Date.now() + timeoutMs; const instance = await getPidLockInfo(home); if (options.instance && instance && !isSamePidLock(instance, options.instance)) { @@ -164,6 +184,9 @@ export async function stopDaemonInstance( if (!instance || !isPidLockOwnerRunning(instance)) { if (instance) await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + // Terminals a `stop --keep-terminals` left behind belong to no daemon now; a plain stop + // closes them. + await closeLeftoverTerminals(home, { keepTerminals: options.keepTerminals, forced: false }); return { action: "not_running", pid: instance?.pid ?? null, @@ -200,9 +223,29 @@ export async function stopDaemonInstance( `Timed out waiting for supervisor PID ${instance.pid} in ${home} to exit${options.force ? "" : "; use --force to permit forced cleanup"}.`, ); await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + // The supervisor already closed the terminals unless it was killed; this confirms it, and + // after --force (no graceful shutdown happened) does it at once. + await closeLeftoverTerminals(home, { keepTerminals: options.keepTerminals, forced }); return { action: "stopped", pid: instance.pid, forced, usedLifecycleRpc }; } +function assertStopOptions(options: { force?: boolean; keepTerminals?: boolean }): void { + if (options.force && options.keepTerminals) { + throw new DaemonInstanceError( + "INVALID_STOP_OPTIONS", + "--force kills terminals; it cannot be combined with --keep-terminals.", + ); + } +} + +async function closeLeftoverTerminals( + home: string, + options: { keepTerminals?: boolean; forced: boolean }, +): Promise { + if (options.keepTerminals) return; + await reapTerminalWorkers({ paseoHome: home, gracefulMs: options.forced ? 0 : undefined }); +} + export async function startDaemonInstance(input: { home: string; command: string; diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index bc5acbc404..317903a592 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -13,6 +13,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:shutdown"; reason: string; + keepTerminals: boolean; } | { type: "paseo:ready"; @@ -22,6 +23,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:restart"; reason?: string; + keepTerminals: boolean; }; interface BootstrapResult { @@ -151,12 +153,15 @@ async function main() { options?: { reason?: string; successExitCode?: number; + keepTerminals?: boolean; }, ) => { const reason = options?.reason ?? `worker_received_${signal}`; + // Signals mean "stop and close terminals"; only an explicit keep request keeps them. + const keepTerminals = options?.keepTerminals === true; if (!shutdownPromise) { logger.info( - { signal, reason, ...getProcessDiagnostics() }, + { signal, reason, keepTerminals, ...getProcessDiagnostics() }, `${signal} received, shutting down gracefully...`, ); @@ -175,7 +180,7 @@ async function main() { clearTimeout(forceExit); return 1; } - await daemon.stop(); + await daemon.stop({ keepTerminals }); clearTimeout(forceExit); logger.info("Server closed"); return options?.successExitCode ?? 0; @@ -209,36 +214,53 @@ async function main() { }; const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => { + const fields = { + clientId: intent.clientId, + requestId: intent.requestId, + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }; if (intent.type === "shutdown") { - logger.warn( - { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason }, - "Shutdown requested via websocket", - ); - if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown", reason: intent.reason })) { + logger.warn(fields, "Shutdown requested via websocket"); + const sent = sendSupervisorLifecycleMessage({ + type: "paseo:shutdown", + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }); + recordLifecycleIntentDispatched(intent.type, fields); + if (sent) { return; } - beginShutdown("shutdown lifecycle intent", { reason: intent.reason }); + beginShutdown("shutdown lifecycle intent", { + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }); return; } - logger.warn( - { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason }, - "Restart requested via websocket", - ); - if ( - sendSupervisorLifecycleMessage({ - type: "paseo:restart", - ...(intent.reason ? { reason: intent.reason } : {}), - }) - ) { + logger.warn(fields, "Restart requested via websocket"); + const sent = sendSupervisorLifecycleMessage({ + type: "paseo:restart", + ...(intent.reason ? { reason: intent.reason } : {}), + keepTerminals: intent.keepTerminals, + }); + recordLifecycleIntentDispatched(intent.type, fields); + if (sent) { return; } beginShutdown("restart lifecycle intent", { reason: intent.reason, successExitCode: 0, + keepTerminals: intent.keepTerminals, }); }; + // Written straight to daemon.log: the pino stream goes through the supervisor's pipe, which + // a paused or busy supervisor does not drain. Tests synchronize on this line. + const recordLifecycleIntentDispatched = (type: string, fields: Record) => { + writeWorkerLifecycleLog(paseoHome, "Lifecycle intent dispatched", { type, ...fields }); + }; + const installSupervisorLivenessGuard = () => { if (typeof process.send !== "function") { return; @@ -278,9 +300,10 @@ async function main() { return; } if (type === "paseo:graceful-shutdown") { - const reason = (message as { reason?: unknown }).reason; + const { reason, keepTerminals } = message as { reason?: unknown; keepTerminals?: unknown }; beginShutdown("Supervisor shutdown request", { reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown", + keepTerminals: keepTerminals === true, }); } }); diff --git a/packages/server/src/server/managed-processes/managed-processes.test.ts b/packages/server/src/server/managed-processes/managed-processes.test.ts index e247db027f..b4be4d16bd 100644 --- a/packages/server/src/server/managed-processes/managed-processes.test.ts +++ b/packages/server/src/server/managed-processes/managed-processes.test.ts @@ -12,6 +12,7 @@ import { type ManagedProcessInspection, type ManagedProcessSnapshot, type ManagedProcessTable, + windowsPowerShellPath, } from "./managed-processes.js"; import { spawnProcess } from "../../utils/spawn.js"; import { @@ -333,7 +334,7 @@ describe("system managed process table", () => { }); expect(commandRunner.commands).toEqual([ { - command: "powershell.exe", + command: windowsPowerShellPath(), args: [ "-NoProfile", "-NonInteractive", @@ -345,6 +346,17 @@ describe("system managed process table", () => { }); }); +describe("windowsPowerShellPath", () => { + test("names Windows PowerShell under SystemRoot, whatever PATH holds", () => { + expect(windowsPowerShellPath({ SystemRoot: "D:\\Win", PATH: "" })).toBe( + "D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(windowsPowerShellPath({})).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + }); +}); + class FakeProcessTable implements ManagedProcessTable { private readonly snapshots: Map; private readonly errorPids: Set; diff --git a/packages/server/src/server/managed-processes/managed-processes.ts b/packages/server/src/server/managed-processes/managed-processes.ts index 0031080c67..43c1b3c810 100644 --- a/packages/server/src/server/managed-processes/managed-processes.ts +++ b/packages/server/src/server/managed-processes/managed-processes.ts @@ -7,6 +7,20 @@ import { writeJsonFileAtomic } from "../atomic-file.js"; import { execCommand } from "../../utils/spawn.js"; import type { ProcessTerminator, TreeKillTarget } from "../../utils/tree-kill.js"; +/** + * Windows PowerShell by its install path. Process identity must not depend on the caller's + * PATH: a daemon or terminal worker can run with a PATH that does not reach System32. + */ +export function windowsPowerShellPath(env: NodeJS.ProcessEnv = process.env): string { + return path.win32.join( + env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + const MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50; @@ -183,7 +197,7 @@ class SystemManagedProcessTable implements ManagedProcessTable { `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`, "if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }", ].join(" "); - const { stdout } = await this.commandRunner.exec("powershell.exe", [ + const { stdout } = await this.commandRunner.exec(windowsPowerShellPath(), [ "-NoProfile", "-NonInteractive", "-Command", diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 4080efd580..1205554d4a 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,3 +1,4 @@ +import type { PluginLifecycle } from "./plugins/lifecycle/index.js"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; @@ -59,6 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult { } export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { + lifecycle?: Pick; workspaceGitService: WorkspaceGitService; workspaceProvisioning: Pick; } @@ -77,6 +79,15 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { + if (createdWorktree.created) { + await deps.lifecycle?.wait("worktree.create.after", { + worktree: { + path: createdWorktree.worktree.worktreePath, + branch: createdWorktree.worktree.branchName, + sourceCheckout: workspaceCwdPlan.sourceCheckout, + }, + }); + } await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, @@ -155,7 +166,7 @@ async function isDirectory(targetPath: string): Promise { async function planWorkspaceCwdForWorktree( inputCwd: string, workspaceGitService: Pick, -): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> { +): Promise<{ inputCwd: string; relativeWorkspaceCwd: string; sourceCheckout: string }> { const normalizedInputCwd = resolve(inputCwd); const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; @@ -163,7 +174,7 @@ async function planWorkspaceCwdForWorktree( if (relativeWorkspaceCwd === null) { throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); } - return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd }; + return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd, sourceCheckout: sourceWorktreePath }; } export async function attemptFirstAgentBranchAutoName(options: { diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 314b44ea47..6f3e7135d9 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -30,13 +30,16 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && "code" in err; } +// The field is assigned explicitly rather than declared as a constructor parameter property: +// the terminal worker loads this module under --experimental-strip-types, which cannot emit +// the assignment a parameter property implies. export class PidLockError extends Error { - constructor( - message: string, - public readonly existingLock?: PidLockInfo, - ) { + readonly existingLock?: PidLockInfo; + + constructor(message: string, existingLock?: PidLockInfo) { super(message); this.name = "PidLockError"; + this.existingLock = existingLock; } } @@ -69,7 +72,7 @@ let cachedBootId: string | null | undefined; // Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while // its host sleeps keeps its uptime, so after it resumes the instant lands after locks its // running supervisor wrote. -function currentBootId(): string | null { +export function currentBootId(): string | null { if (cachedBootId === undefined) { try { cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; @@ -80,10 +83,17 @@ function currentBootId(): string | null { return cachedBootId; } -function writtenDuringThisBoot(lock: PidLockInfo): boolean { +/** + * Whether a record stamped with `startedAt` (and, on Linux, `bootId`) was written during the + * current boot. Daemon locks and terminal worker records share this rule. + */ +export function writtenDuringThisBoot(stamp: { + startedAt: string; + bootId?: string | null; +}): boolean { const thisBoot = currentBootId(); - if (lock.bootId && thisBoot) return lock.bootId === thisBoot; - return !precedesThisBoot(lock.startedAt); + if (stamp.bootId && thisBoot) return stamp.bootId === thisBoot; + return !precedesThisBoot(stamp.startedAt); } /** diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 1cfad77e05..9f62075125 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -12,7 +12,7 @@ test("the configuration example saves Codex options and injects launch-only MCP const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true, @@ -25,7 +25,7 @@ test("the configuration example saves Codex options and injects launch-only MCP }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.13.2", + appVersion: "0.14.0-beta.1", }); try { await client.connect(); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts index 5b03bae09b..b652c569fe 100644 --- a/packages/server/src/server/plugins/agent-tools.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -80,7 +80,7 @@ const tool = defineAgentTool({ }, }); export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels.x !== "y") return; return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] }; }); @@ -97,14 +97,14 @@ async function setup( const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-")); const recorder = recordingProvider(native, providerOptions); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: recorder.provider }, mcpEnabled: true, providerOverrides: { codex: { paseoTools: policy } }, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.13.2", + appVersion: "0.14.0-beta.1", }); const mcps: Client[] = []; async function install(id: string, code: string) { @@ -112,7 +112,7 @@ async function setup( await mkdir(pluginDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }), + JSON.stringify({ id, requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile(path.join(pluginDirectory, "index.server.ts"), code); await client.installDirectoryPlugin(pluginDirectory); @@ -305,7 +305,7 @@ test("each plugin owns its tool array and callbacks can remove earlier attachmen const source = pluginSource.replace( " return () => {};", ` - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); return { ...request, tools: [] }; }); @@ -501,12 +501,12 @@ test("built-in plugins attach and execute tools while configured plugins are dis await mkdir(directory); await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }), + JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); const recorder = recordingProvider(true); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: recorder.provider }, pluginsEnabled: false, builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), @@ -762,7 +762,7 @@ test("the agent-tools example appends notes only for labeled agents", async () = const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; const mcpSource = ` export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.provider !== "codex") return; return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env: { ...request.env, OPEN_REASON: request.reason } }; @@ -993,16 +993,16 @@ test("session-open MCP maps are plugin-local and accumulate only across their ow "z-extend", ` export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; }); - server.before("agent.session_open", () => undefined); + server.addHook("agent.open.before", async () => undefined); return () => {}; }`, ); @@ -1016,7 +1016,7 @@ export default function contribute(server) { await h.install( "zz-remove", `export default function contribute(server) { - server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} })); + server.addHook("agent.open.before", async ({ request }) => ({ ...request, mcpServers: {} })); return () => {}; }`, ); @@ -1034,7 +1034,7 @@ test("session-open MCP configs are validated before a provider launches", async mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), ); try { - await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s); + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*agent.open.before/s); expect(h.launches).toHaveLength(0); } finally { await h.close(); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index cf6ffe9eee..463ee8407d 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -14,6 +14,7 @@ export const builtinPlugins = [ "minimax-usage-source", "muse-provider", "opencode-go-usage-source", + "worktree-include", "zai-usage-source", ] as const; diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 4ca5aeefd5..17f25fce3b 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import pino from "pino"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { DaemonConfigStore } from "../daemon-config-store.js"; import { PluginService } from "./index.js"; import { BuiltinPluginLoader } from "./builtin/index.js"; @@ -1321,7 +1321,11 @@ export default function contribute(plugin: unknown) { }); describe("npm plugin installation", () => { - it("preserves dependencies and metadata, and reloads without npm", async () => { + let registry: Awaited>; + + // Packing the registry starts eleven real npm processes. Give fixture construction its + // own setup budget so Windows process startup does not consume the installation journey. + beforeAll(async () => { const packages = npmPluginPackages(); const fixture = packages[1]; packages.push({ ...fixture, version: "2.0.0", tags: ["future"] }); @@ -1356,7 +1360,7 @@ describe("npm plugin installation", () => { for (const [name, overrides] of failures) { failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } }); } - const registry = await startNpmRegistry([ + registry = await startNpmRegistry([ ...packages, { name: "paseo-prepared-plugin", @@ -1386,6 +1390,9 @@ export default function contribute() { }, ...failedPackages, ]); + }, 120_000); + + it("preserves dependencies and metadata, and reloads without npm", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-")); roots.push(home); const previousConfig = process.env.npm_config_userconfig; diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 98e7ce5623..5dad547d5c 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -45,6 +45,7 @@ interface PluginRuntimePort { emit?: PluginLifecycle["emit"]; drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; + wait?: PluginLifecycle["wait"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; @@ -168,6 +169,10 @@ export class PluginService { return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); }; + readonly wait: PluginLifecycle["wait"] = async (name, payload) => { + await this.runtime.wait?.(name, payload); + }; + async drainEvents(): Promise { await this.runtime.drainEvents?.(); } diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 6f3d8661e7..1b05b56b53 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -8,8 +8,11 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -39,7 +42,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id); }) .toEqual([closed.id]); @@ -49,7 +52,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", const logs = await client.getPluginLogs("lifecycle-logger"); return logs .filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }) .map((entry) => { return JSON.parse(entry.message).data.agent.id; @@ -61,7 +64,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id) .sort(); }) @@ -75,20 +78,23 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); const completedFile = path.join(directory, "closed.txt"); try { await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile( path.join(directory, "index.server.ts"), `import { writeFile } from "node:fs/promises"; import { setTimeout } from "node:timers/promises"; export default function contribute(server) { - server.on("agent.closed", async (event, context) => { + server.addHook("agent.close.after", async (event, context) => { await setTimeout(200, undefined, { signal: context.signal }); await writeFile(${JSON.stringify(completedFile)}, event.agent.id); }); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 9c75f63746..bd1dfa8859 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,6 +7,7 @@ import { expect, onTestFinished, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { resolveDaemonVersion } from "../daemon-version.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); @@ -74,6 +76,76 @@ export default function contribute(server) { } }, 60_000); +test("addHook transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "lifecycle", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.addHook("workspace.create.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); + return { ...request, title: "Created through a hook" }; + }); + server.addHook("workspace.create.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.create.after", event })); + }); + server.addHook("workspace.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.archive.after", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(created.workspace?.name).toBe("Created through a hook"); + const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + await client.archiveWorkspace(workspaceId); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { hook: "workspace.before" }, + { + hook: "workspace.create.after", + event: { workspace: { id: workspaceId, cwd: directory } }, + }, + { hook: "workspace.archive.after", event: { workspace: { id: workspaceId } } }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + test("plugins observe turns, answer permissions, and observe archive without blocking the agent", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); @@ -87,24 +159,24 @@ test("plugins observe turns, answer permissions, and observe archive without blo path.join(directory, "index.server.ts"), ` export default function contribute(server) { - server.on("agent.turn_started", (event) => { - console.log(JSON.stringify({ hook: "agent.turn_started", event })); + server.addHook("agent.turn.before", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.before", event })); }); - server.on("agent.turn_ended", (event) => { - console.log(JSON.stringify({ hook: "agent.turn_ended", event })); + server.addHook("agent.turn.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.after", event })); }); - server.on("agent.permission_requested", async (event, context) => { - console.log(JSON.stringify({ hook: "agent.permission_requested", event })); + server.addHook("agent.permission.before", async (event, context) => { + console.log(JSON.stringify({ hook: "agent.permission.before", event })); await context.paseo.agents.ref(event.agent.id).respondToPermission({ requestId: event.request.id, response: { behavior: "deny", message: "Declined by plugin" }, }); }); - server.on("agent.permission_resolved", (event) => { - console.log(JSON.stringify({ hook: "agent.permission_resolved", event })); + server.addHook("agent.permission.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.permission.after", event })); }); - server.on("agent.archived", (event) => { - console.log(JSON.stringify({ hook: "agent.archived", event })); + server.addHook("agent.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.archive.after", event })); }); return () => {}; } @@ -139,16 +211,16 @@ export default function contribute(server) { { timeout: 10_000 }, ) .toMatchObject([ + { hook: "agent.permission.after", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.permission_requested", + hook: "agent.permission.before", event: { request: { input: { command: "rm -f permission.txt" } } }, }, - { hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.turn_ended", + hook: "agent.turn.after", event: { outcome: { kind: "completed" }, timeline: [] }, }, - { hook: "agent.turn_started", event: { agent: { id: agent.id } } }, + { hook: "agent.turn.before", event: { agent: { id: agent.id } } }, ]); const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 }); expect(page.entries.length).toBeGreaterThan(0); @@ -157,7 +229,7 @@ export default function contribute(server) { .poll(async () => { const logs = await client.getPluginLogs("turn-hooks"); return logs.filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }).length; }) .toBe(1); @@ -170,44 +242,41 @@ export default function contribute(server) { test("agent creation hooks change the provider and environment before the session opens", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); - try { - await writeFile( - path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "agent-hooks", requirements: { paseo: ">=0.8.0" } }), - ); - await writeFile( - path.join(directory, "index.server.ts"), - ` -export default function contribute(server) { - server.before("agent.create", ({ request }) => { - return { ...request, config: { ...request.config, provider: "codex" }, env: { ...request.env, HOOK_CREATE: "created" } }; + const pluginDirectory = await createProviderPlugin(directory, { + acknowledgeClose: true, + hooks: ` + server.addHook("agent.create.before", async ({ request }) => { + return { ...request, config: { ...request.config, provider: "${PROVIDER_ID}", model: "shutdown-model" }, env: { ...request.env, HOOK_CREATE: "created" } }; }); - server.before("agent.session_open", ({ request }) => { - console.log(JSON.stringify({ hook: "agent.session_open", request })); + server.addHook("agent.open.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "agent.open.before", request })); return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; }); - server.on("agent.created", (event) => { - console.log(JSON.stringify({ hook: "agent.created", event })); + server.addHook("agent.create.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.create.after", event })); + });`, + reportOpen: true, }); - return () => {}; -} -`, - ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { await client.connect(); await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); - await client.installDirectoryPlugin(directory); + await client.installDirectoryPlugin(pluginDirectory); const agent = await client.createAgent({ provider: "claude", cwd: directory, title: "Hook test", }); - expect(agent.provider).toBe("codex"); + expect(agent.provider).toBe(PROVIDER_ID); + expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({ + provider: PROVIDER_ID, + config: { model: "shutdown-model" }, + }); await expect .poll(async () => { - const logs = await client.getPluginLogs("agent-hooks"); + const logs = await client.getPluginLogs("shutdown-provider-plugin"); return logs .filter((entry) => { return entry.message.startsWith('{"hook":'); @@ -218,10 +287,17 @@ export default function contribute(server) { }) .toMatchObject([ { - hook: "agent.session_open", + hook: "agent.open.before", request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, }, - { hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } }, + { + hook: "provider.open", + config: { env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" } }, + }, + { + hook: "agent.create.after", + event: { agent: { id: agent.id, provider: PROVIDER_ID } }, + }, ]); await client.archiveAgent(agent.id); } finally { @@ -244,10 +320,10 @@ test("invalid output from an untyped plugin rejects creation before later callba path.join(directory, "index.server.ts"), ` export default function contribute(server) { - server.before("workspace.create", () => { + server.addHook("workspace.create.before", async () => { return { source: { kind: "worktree", branchName: 42 } }; }); - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { console.log("Unexpected later callback"); return request; }); @@ -260,7 +336,7 @@ export default function contribute(server) { await client.installDirectoryPlugin(directory); const result = await client.createWorkspace({ source: { kind: "directory", path: directory } }); expect(result.workspace).toBeNull(); - expect(result.error).toContain("Plugin invalid-hook before workspace.create failed"); + expect(result.error).toContain("Plugin invalid-hook workspace.create.before failed"); const logs = await client.getPluginLogs("invalid-hook"); expect( logs.some((entry) => { @@ -278,7 +354,9 @@ const PROVIDER_ID = "shutdown-provider"; async function createProviderPlugin( root: string, - options: { acknowledgeClose: boolean } = { acknowledgeClose: true }, + options: { acknowledgeClose: boolean; hooks?: string; reportOpen?: boolean } = { + acknowledgeClose: true, + }, ): Promise { const directory = path.join(root, "plugin"); await mkdir(directory, { recursive: true }); @@ -324,6 +402,7 @@ const provider: ProviderRegistration = { return; } if (input.type === "session.open") { + if (${JSON.stringify(options.reportOpen ?? false)}) console.log(JSON.stringify({ hook: "provider.open", config: input.config })); emit({ type: "session.opened", requestId: input.requestId, @@ -375,6 +454,7 @@ const provider: ProviderRegistration = { export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) { server.registerProvider(provider); +${options.hooks ?? ""} return () => undefined; }`, ); @@ -383,7 +463,7 @@ export default function contribute(server: { registerProvider(p: ProviderRegistr test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -427,7 +507,7 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -476,7 +556,7 @@ test("reloading a plugin leaves a completed plugin-provider agent without an err test("a provider that never acknowledges session.close does not hold the daemon open", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false }); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -586,3 +666,644 @@ test("reloading a plugin does not send on a closed IPC channel", async () => { await rm(pluginDirectory, { recursive: true, force: true }); } }, 60_000); + +test("waiting hook failures reject the operation while notification failures are logged and later handlers run", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hook-failures-")); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "hook-failures", + requirements: { paseo: ">=0.14.0-beta.1" }, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.addHook("workspace.create.before", async ({ request }) => { + if (request.title === "reject") throw new Error("waiting hook rejected"); + return request; + }); + server.addHook("workspace.create.after", async () => { + throw new Error("notification rejected"); + }); + server.addHook("workspace.create.after", async ({ workspace }) => { + console.log("Later notification for " + workspace.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const failed = await client.createWorkspace({ + title: "reject", + source: { kind: "directory", path: directory }, + }); + expect(failed.workspace).toBeNull(); + expect(failed.error).toContain( + "Plugin hook-failures workspace.create.before failed: waiting hook rejected", + ); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceDirectory).toBe(directory); + await expect + .poll(async () => + (await client.getPluginLogs("hook-failures")).map(({ message }) => message).join("\n"), + ) + .toContain("Later notification for " + created.workspace!.id); + const messages = (await client.getPluginLogs("hook-failures")) + .map(({ message }) => message) + .join("\n"); + expect(messages).toContain("Lifecycle hook workspace.create.after failed"); + expect(messages).toContain("notification rejected"); + expect(messages).toContain("Later notification for " + created.workspace!.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test.each([false, true])( + "worktree creation waits for plugins before its first agent and rolls back failures (firstAgentContext: %s)", + async (withFirstAgentContext) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-hook-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + execFileSync("git", ["checkout", "-b", "plugin-base"], { cwd: repository, stdio: "pipe" }); + await writeFile(path.join(repository, "from-plugin-base.txt"), "base"); + execFileSync("git", ["add", "."], { cwd: repository, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "plugin base", + ], + { cwd: repository, stdio: "pipe" }, + ); + execFileSync("git", ["checkout", "main"], { cwd: repository, stdio: "pipe" }); + const orderFile = path.join(root, "order.jsonl"); + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("worktree.create.before", async ({ request }) => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.before", request }) + "\\n"); + return { ...request, path: ${JSON.stringify(path.join(root, "elsewhere"))} + "/" + request.slug, + branch: request.branch === "accept-hook" ? "plugin-branch" : request.branch, + baseRef: request.branch === "accept-hook" ? "plugin-base" : request.baseRef }; + }); + server.addHook("worktree.create.after", async ({ worktree }) => { + const { appendFile, writeFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n"); + if (worktree.branch === "reject-hook") throw new Error("worktree rejected"); + await writeFile(worktree.path + "/plugin-ready.txt", "ready"); + }); + server.addHook("workspace.create.after", async () => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "workspace.create.after" }) + "\\n"); + }); + server.addHook("agent.create.before", async ({ request }) => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "agent.create.before" }) + "\\n"); + return request; + }); + server.addHook("agent.open.before", async ({ request }) => { + const { readFile } = await import("node:fs/promises"); + const contents = await readFile(request.cwd + "/plugin-ready.txt", "utf8"); + if (contents !== "ready") throw new Error("missing plugin output"); + console.log(JSON.stringify({ hook: "first-agent-ready", agentId: request.agentId, cwd: request.cwd, contents })); + return request; + });`, + }); + let daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "state"), + cleanup: false, + }); + let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + const installed = await client.installDirectoryPlugin(pluginDirectory); + expect(installed.status).toBe("running"); + const created = await client.createWorkspace({ + ...(withFirstAgentContext + ? { + firstAgentContext: { prompt: "Check the initialized worktree" }, + agent: { + provider: PROVIDER_ID, + cwd: repository, + initialPrompt: "Check the initialized worktree", + }, + } + : {}), + source: { + kind: "worktree", + cwd: repository, + branchName: "accept-hook", + worktreeSlug: "accept-hook", + }, + }); + expect(created.error).toBeNull(); + const cwd = created.workspace!.workspaceDirectory; + expect(created.workspace!.gitRuntime?.currentBranch).toBe("plugin-branch"); + const persisted = JSON.parse( + await readFile(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ); + expect(persisted).toContainEqual( + expect.objectContaining({ + workspaceId: created.workspace!.id, + branch: "plugin-branch", + baseBranch: "refs/heads/plugin-base", + worktreeRoot: cwd, + }), + ); + expect(cwd).toBe(path.join(await realpath(root), "elsewhere", "accept-hook")); + expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base"); + expect( + execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim(), + ).toBe("plugin-branch"); + expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready"); + const firstAgent = withFirstAgentContext + ? created.agent! + : await client.createAgent({ + provider: PROVIDER_ID, + cwd, + workspaceId: created.workspace!.id, + }); + expect(firstAgent).toMatchObject({ + cwd, + workspaceId: created.workspace!.id, + provider: PROVIDER_ID, + }); + await expect + .poll(async () => + (await client.getPluginLogs("shutdown-provider-plugin")) + .filter(({ message }) => message.startsWith('{"hook":"first-agent-ready"')) + .map(({ message }) => JSON.parse(message)), + ) + .toContainEqual({ + hook: "first-agent-ready", + agentId: firstAgent.id, + cwd, + contents: "ready", + }); + const records = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.map((record) => record.hook)).toEqual([ + "worktree.create.before", + "worktree.create.after", + "workspace.create.after", + "agent.create.before", + ]); + expect({ + ...records[1].worktree, + sourceCheckout: await realpath(records[1].worktree.sourceCheckout), + }).toEqual({ + path: cwd, + branch: "plugin-branch", + sourceCheckout: await realpath(repository), + }); + expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain( + created.workspace!.id, + ); + await client.archiveWorkspace(created.workspace!.id); + await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + const hookRecordsBeforeRestore = await readFile(orderFile, "utf8"); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "state"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.restoreWorkspace(created.workspace!.id); + expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base"); + expect(await readFile(orderFile, "utf8")).toBe(hookRecordsBeforeRestore); + const beforeFailure = await client.fetchWorkspaces(); + const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }); + const rejected = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "reject-hook", + worktreeSlug: "reject-hook", + }, + }); + expect(rejected.workspace).toBeNull(); + expect(rejected.error).toContain( + "Plugin shutdown-provider-plugin worktree.create.after failed: worktree rejected", + ); + const failedRecords = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const failedPath = failedRecords.at(-1).worktree.path; + await expect(stat(failedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual( + beforeFailure.entries.map(({ id }) => id).sort(), + ); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }), + ).toBe(worktreesBeforeFailure); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 60_000, +); + +test("worktree before hook rejects unsafe destinations with plugin attribution before creating branches", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-before-invalid-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + const occupied = path.join(root, "occupied"); + await mkdir(occupied); + const destinations = [ + occupied, + "relative/path", + repository, + path.join(repository, ".git", "worktree"), + root, + path.parse(root).root, + (await import("node:os")).homedir(), + ]; + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("worktree.create.before", async ({ request }) => { + if (request.branch === "throws") throw new Error("creation denied"); + if (request.branch === "readonly") return { ...request, repoRoot: "/different-repository" }; + return { ...request, path: ${JSON.stringify(destinations)}[Number(request.branch.slice(4))] }; + });`, + }); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + for (const branchName of [ + ...destinations.map((_, index) => `bad-${index}`), + "throws", + "readonly", + ]) { + const created = await client.createWorkspace({ + source: { kind: "worktree", cwd: repository, branchName, worktreeSlug: branchName }, + }); + expect(created.workspace).toBeNull(); + expect(created.error).toContain( + "Plugin shutdown-provider-plugin worktree.create.before failed:", + ); + expect( + execFileSync("git", ["branch", "--format=%(refname:short)"], { + cwd: repository, + encoding: "utf8", + }).trim(), + ).toBe("main"); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }).match(/^worktree /gm), + ).toHaveLength(1); + } + expect((await client.fetchWorkspaces()).entries).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); + +test("the placement example creates a sibling worktree, opens its first agent there, lists and archives it", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-placement-example-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + const providerDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + reportOpen: true, + }); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(providerDirectory); + const installed = await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/worktree-placement", import.meta.url)), + ); + expect(installed.status).toBe("running"); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "feature/x", + worktreeSlug: "feature-x", + }, + }); + expect(created.error).toBeNull(); + const cwd = path.join(await realpath(root), "repository.feature-x"); + expect(created.workspace!.workspaceDirectory).toBe(cwd); + expect((await stat(cwd)).isDirectory()).toBe(true); + expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain( + created.workspace!.id, + ); + const worktrees = await client.getPaseoWorktreeList({ cwd: repository }); + expect(worktrees.error).toBeNull(); + expect(worktrees.worktrees).toContainEqual( + expect.objectContaining({ worktreePath: cwd, branchName: "feature/x" }), + ); + const collision = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "feature/x", + worktreeSlug: "another-slug", + }, + }); + expect(collision.workspace).toBeNull(); + expect(collision.error).toContain("Plugin worktree-placement worktree.create.before failed:"); + expect(collision.error).toContain("already exists"); + const agent = await client.createAgent({ + provider: PROVIDER_ID, + cwd, + workspaceId: created.workspace!.id, + }); + expect(agent.cwd).toBe(cwd); + const logs = await client.getPluginLogs("shutdown-provider-plugin"); + const opened = logs + .filter((entry) => entry.message.startsWith('{"hook":"provider.open"')) + .map((entry) => JSON.parse(entry.message)); + expect(opened.map((entry) => entry.config.cwd)).toContain(cwd); + await client.archiveWorkspace(created.workspace!.id); + await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); + +test("worktree includes reach the first agent without copying tracked or unignored files", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + const git = (...args: string[]) => execFileSync("git", args, { cwd: repository, stdio: "pipe" }); + await mkdir(path.join(repository, "config")); + await writeFile(path.join(repository, ".gitignore"), ".env\nconfig/*.local.json\n"); + await writeFile( + path.join(repository, ".worktreeinclude"), + ".env\nconfig/*.local.json\nunignored.txt\n", + ); + await writeFile(path.join(repository, "config/tracked.local.json"), "git version"); + git("add", ".gitignore", ".worktreeinclude"); + git("add", "-f", "config/tracked.local.json"); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "initial", + ); + await writeFile(path.join(repository, ".env"), "SECRET=source"); + await writeFile(path.join(repository, "config/app.local.json"), "local config"); + await writeFile(path.join(repository, "config/tracked.local.json"), "uncommitted source version"); + await writeFile(path.join(repository, "unignored.txt"), "unignored source"); + const observed = path.join(root, "observed.json"); + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("agent.open.before", async ({ request }) => { + const { readFile, writeFile } = await import("node:fs/promises"); + const contents = await Promise.all([".env", "config/app.local.json", "config/tracked.local.json"].map(file => readFile(request.cwd + "/" + file, "utf8"))); + await writeFile(${JSON.stringify(observed)}, JSON.stringify(contents)); + });`, + }); + const daemon = await createTestPaseoDaemon({ builtinPlugins: new BuiltinPluginLoader() }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + expect((await client.installDirectoryPlugin(pluginDirectory)).status).toBe("running"); + const created = await client.createWorkspace({ + source: { kind: "worktree", cwd: repository, branchName: "include", worktreeSlug: "include" }, + }); + expect(created.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: created.workspace!.workspaceDirectory, + workspaceId: created.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=source", + "local config", + "git version", + ]); + await expect( + stat(path.join(created.workspace!.workspaceDirectory, "unignored.txt")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + const sourceWorktree = created.workspace!.workspaceDirectory; + await writeFile(path.join(sourceWorktree, ".env"), "SECRET=worktree"); + await writeFile(path.join(sourceWorktree, "config/app.local.json"), "worktree config"); + const nested = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: sourceWorktree, + branchName: "nested", + worktreeSlug: "nested", + }, + }); + expect(nested.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: nested.workspace!.workspaceDirectory, + workspaceId: nested.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=worktree", + "worktree config", + "git version", + ]); + + execFileSync("git", ["add", "-f", ".env"], { cwd: sourceWorktree, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "commit branch env", + ], + { cwd: sourceWorktree, stdio: "pipe" }, + ); + const existing = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "include", + branchName: "existing", + worktreeSlug: "existing", + }, + }); + expect(existing.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: existing.workspace!.workspaceDirectory, + workspaceId: existing.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=worktree", + "local config", + "git version", + ]); + + await rm(path.join(repository, ".worktreeinclude")); + const noIncludes = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "main", + branchName: "no-includes", + worktreeSlug: "no-includes", + }, + }); + expect(noIncludes.error).toBeNull(); + await expect( + stat(path.join(noIncludes.workspace!.workspaceDirectory, ".env")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + stat(path.join(noIncludes.workspace!.workspaceDirectory, "config/app.local.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + await writeFile(path.join(repository, ".worktreeinclude"), ".env\nconfig/*.local.json\n"); + await rm(path.join(sourceWorktree, "config"), { recursive: true }); + await writeFile( + path.join(sourceWorktree, "config"), + "committed file blocks the config directory", + ); + execFileSync("git", ["add", "-A", "config"], { cwd: sourceWorktree, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "block config directory", + ], + { cwd: sourceWorktree, stdio: "pipe" }, + ); + const beforeFailure = await client.fetchWorkspaces(); + const worktreesBeforeFailure = git("worktree", "list", "--porcelain").toString(); + const failed = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "include", + branchName: "copy-failure", + worktreeSlug: "copy-failure", + }, + }); + expect(failed.workspace).toBeNull(); + expect(failed.error).toContain("Plugin worktree-include worktree.create.after failed:"); + expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual( + beforeFailure.entries.map(({ id }) => id).sort(), + ); + expect(git("worktree", "list", "--porcelain").toString()).toBe(worktreesBeforeFailure); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index fc9cfbd84d..40de1215d8 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; import { expect, test } from "vitest"; -import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import type { PluginHookRequests, PluginHookEvents } from "@getpaseo/plugin/server"; import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -14,14 +14,14 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; type RecordedHook = | { - [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] }; - }[keyof PluginLifecycleEvents] + [Name in keyof PluginHookEvents]: { hook: Name; data: PluginHookEvents[Name] }; + }[keyof PluginHookEvents] | { - [Name in keyof PluginBeforeRequests]: { - hook: `before ${Name}`; - data: { request: PluginBeforeRequests[Name] }; + [Name in keyof PluginHookRequests]: { + hook: Name; + data: { request: PluginHookRequests[Name] }; }; - }[keyof PluginBeforeRequests]; + }[keyof PluginHookRequests]; const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); @@ -39,8 +39,8 @@ async function readHooks(client: DaemonClient): Promise { async function endedTurns(client: DaemonClient, agentId: string) { const events = await readHooks(client); - return events.filter((event): event is Extract => { - return event.hook === "agent.turn_ended" && event.data.agent.id === agentId; + return events.filter((event): event is Extract => { + return event.hook === "agent.turn.after" && event.data.agent.id === agentId; }); } @@ -63,7 +63,7 @@ async function waitForPermission(client: DaemonClient, agentId: string, command: const events = await readHooks(client); for (const event of events) { if ( - event.hook === "agent.permission_requested" && + event.hook === "agent.permission.before" && event.data.agent.id === agentId && event.data.request.input?.command === command ) { @@ -111,7 +111,7 @@ test.skipIf(process.platform !== "linux")( }); const logger = pino({ level: "info" }, destination); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", + daemonVersion: "0.14.0-beta.1", logger, agentClients: { claude: new ClaudeAgentClient({ logger }), @@ -121,7 +121,7 @@ test.skipIf(process.platform !== "linux")( }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.8.0", + appVersion: "0.14.0-beta.1", }); const proof: Record = { port: daemon.port, @@ -169,7 +169,7 @@ test.skipIf(process.platform !== "linux")( const approvalId = await waitForPermission(client, agent.id, "git status"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: approvalId, resolution: expect.objectContaining({ behavior: "allow" }), @@ -186,7 +186,7 @@ test.skipIf(process.platform !== "linux")( const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: deniedId, resolution: expect.objectContaining({ behavior: "deny" }), @@ -224,7 +224,7 @@ test.skipIf(process.platform !== "linux")( .poll( async () => { return (await readHooks(client)).filter((event) => { - return event.hook === "agent.turn_started" && event.data.agent.id === agent.id; + return event.hook === "agent.turn.before" && event.data.agent.id === agent.id; }).length; }, { timeout: 30_000 }, @@ -278,23 +278,26 @@ test.skipIf(process.platform !== "linux")( ); return [...names].sort(); }) - .toEqual([ - "agent.archived", - "agent.closed", - "agent.created", - "agent.permission_requested", - "agent.permission_resolved", - "agent.turn_ended", - "agent.turn_started", - "before agent.create", - "before agent.session_open", - "before workspace.create", - "workspace.archived", - "workspace.created", - ]); + .toEqual( + [ + "agent.archive.after", + "agent.close.after", + "agent.create.after", + "agent.permission.before", + "agent.permission.after", + "agent.turn.after", + "agent.turn.before", + "agent.create.before", + "agent.open.before", + "worktree.create.after", + "workspace.create.before", + "workspace.archive.after", + "workspace.create.after", + ].sort(), + ); const openReasons = (await readHooks(client)) .filter((event) => { - return event.hook === "before agent.session_open"; + return event.hook === "agent.open.before"; }) .map((event) => { return event.data.request.reason; diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index fe86f6a754..e17cca1ed5 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { createPaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { PluginHookHandlers } from "./index.js"; +import { validateBeforeResult, PluginHookHandlers } from "./index.js"; const paseo = createPaseoApi( new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), @@ -9,18 +9,17 @@ const paseo = createPaseoApi( test("removing an old registration twice preserves a newer registration for the same hook", async () => { const hooks = new PluginHookHandlers(() => {}); - const remove = hooks.before("workspace.create", ({ request }) => { + const remove = hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "old" }; }); remove(); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "new" }; }); remove(); const output = await hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -31,17 +30,17 @@ test("removing an old registration twice preserves a newer registration for the test("before hooks compose returned requests and preserve the original input", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "first" }; }); - hooks.before("workspace.create", () => { + hooks.addHook("workspace.create.before", async () => { return; }); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: request.title + ":second" }; }); const input = { source: { kind: "directory", path: "/project" } }; - expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({ + expect(await hooks.invoke("operation", "workspace.create.before", input, paseo)).toEqual({ source: { kind: "directory", path: "/project" }, title: "first:second", }); @@ -50,7 +49,7 @@ test("before hooks compose returned requests and preserve the original input", a test("teardown aborts an active callback and removes its registrations", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", async (_input, context) => { + hooks.addHook("workspace.create.before", async (_input, context) => { await new Promise((_resolve, reject) => { context.signal.addEventListener( "abort", @@ -63,8 +62,7 @@ test("teardown aborts an active callback and removes its registrations", async ( }); const invocation = hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -72,19 +70,18 @@ test("teardown aborts an active callback and removes its registrations", async ( ); hooks.close(); await expect(invocation).rejects.toThrow("Hook aborted"); - expect(hooks.catalog()).toEqual({ events: [], before: [] }); + expect(hooks.catalog()).toEqual([]); }); test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("agent.session_open", ({ request }) => { + hooks.addHook("agent.open.before", async ({ request }) => { return { ...request, provider: "another-provider" }; }); await expect( hooks.invoke( "operation", - "before", - "agent.session_open", + "agent.open.before", { agentId: "agent", workspaceId: "workspace", @@ -100,19 +97,18 @@ test("session-open hooks reject changes to session identity instead of silently }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env"); + ).rejects.toThrow("agent.open.before hooks can only change env"); }); test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( "session-open targeting metadata is read-only: %j", async (patch) => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch })); + hooks.addHook("agent.open.before", async ({ request }) => ({ ...request, ...patch })); await expect( hooks.invoke( "operation", - "before", - "agent.session_open", + "agent.open.before", { agentId: "agent", workspaceId: null, @@ -128,6 +124,132 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers"); + ).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers"); }, ); + +test("new request-changing registrations compose with legacy registrations in registration order", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", ({ request }) => ({ ...request, title: "legacy" })); + hooks.addHook("workspace.create.before", async ({ request }) => ({ + ...request, + title: request.title + ":new", + })); + hooks.before("workspace.create", ({ request }) => ({ + ...request, + title: request.title + ":last", + })); + expect( + await hooks.invoke( + "operation", + "workspace.create.before", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ), + ).toEqual({ source: { kind: "directory", path: "/project" }, title: "legacy:new:last" }); +}); + +test.each(["agent.session_open", "agent.create.prepare", "nonsense"])( + "addHook rejects unsupported name %s at registration", + (name) => { + const hooks = new PluginHookHandlers(() => {}); + // @ts-expect-error Runtime validation also protects untyped plugins. + expect(() => hooks.addHook(name, async () => {})).toThrow(name); + }, +); + +test.each([ + ["workspace.create", "workspace.create.before"], + ["workspace.created", "workspace.create.after"], + ["workspace.archived", "workspace.archive.after"], + ["agent.create", "agent.create.before"], + ["agent.created", "agent.create.after"], + ["agent.session_open", "agent.open.before"], + ["agent.closed", "agent.close.after"], + ["agent.archived", "agent.archive.after"], + ["agent.turn_started", "agent.turn.before"], + ["agent.turn_ended", "agent.turn.after"], + ["agent.permission_requested", "agent.permission.before"], + ["agent.permission_resolved", "agent.permission.after"], +] as const)("legacy %s registers only %s across the process boundary", (oldName, newName) => { + const hooks = new PluginHookHandlers(() => {}); + const handler = async () => {}; + const remove = + oldName === "workspace.create" || oldName === "agent.create" || oldName === "agent.session_open" + ? hooks.before(oldName, handler) + : hooks.on(oldName, handler); + hooks.addHook(newName, async () => {}); + expect(hooks.catalog()).toEqual([newName]); + remove(); + expect(hooks.catalog()).toEqual([newName]); + hooks.close(); + expect(hooks.catalog()).toEqual([]); +}); + +test("legacy notifications and addHook share payloads, registration order, and cleanup", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: unknown[] = []; + const remove = hooks.on("workspace.created", (event) => { + received.push(["old", event]); + }); + hooks.addHook("workspace.create.after", async (event) => { + received.push(["new", event]); + }); + const payload = { + workspace: { + id: "workspace", + projectId: "project", + cwd: "/project", + name: null, + archivedAt: null, + }, + }; + await hooks.invoke("first", "workspace.create.after", payload, paseo); + remove(); + remove(); + await hooks.invoke("second", "workspace.create.after", payload, paseo); + expect(received).toEqual([ + ["old", payload], + ["new", payload], + ["new", payload], + ]); +}); + +test("a waiting after hook propagates failures and stops later handlers", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: string[] = []; + hooks.addHook("worktree.create.after", async ({ worktree }) => { + received.push(worktree.path); + throw new Error("cannot initialize worktree"); + }); + hooks.addHook("worktree.create.after", async () => { + received.push("unexpected later handler"); + }); + await expect( + hooks.invoke( + "worktree", + "worktree.create.after", + { + worktree: { path: "/worktree", branch: "feature", sourceCheckout: "/source" }, + }, + paseo, + ), + ).rejects.toThrow("cannot initialize worktree"); + expect(received).toEqual(["/worktree"]); +}); + +test("checkout-branch hooks reject a branch-off baseRef", async () => { + const request = { + kind: "checkout-branch" as const, + repoRoot: "/repo", + sourceCheckout: "/repo", + branch: "feature", + slug: "feature", + path: "/work/feature", + }; + await expect( + validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }), + ).rejects.toThrow(/baseRef/); +}); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index c43e113e38..d4f58cc78c 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -1,3 +1,8 @@ +import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; +import { + createLifecycleRegistration, + pluginHookRegistry, +} from "@getpaseo/plugin/internal/lifecycle"; import { isDeepStrictEqual } from "node:util"; import { serializeAgentTool, @@ -11,43 +16,83 @@ import { z } from "zod"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; import type { PluginHookAgent, + PluginHookPayload, PluginHookContext, PluginLifecycleRegistration, } from "@getpaseo/plugin/server"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { - PluginBeforeRequests, + PluginHookRequests, PluginHookWorkspace, - PluginLifecycleEvents, + PluginHookEvents, } from "@getpaseo/plugin/server"; import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; -export type PluginWireBeforeRequests = Omit & { - "agent.session_open": SessionOpenWireRequest; +export type PluginWireBeforeRequests = Omit & { + "agent.open.before": SessionOpenWireRequest; }; /** Daemon-only results; ownership never crosses the plugin process boundary. */ export type PluginBeforeResults = PluginWireBeforeRequests & { - "agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record }; + "agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record }; }; -export const lifecycleEventNames = [ - "agent.created", - "agent.closed", - "agent.turn_started", - "agent.turn_ended", - "agent.permission_requested", - "agent.permission_resolved", - "agent.archived", - "workspace.created", - "workspace.archived", -] as const; -export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const; +const worktreeRequestFields = { + repoRoot: z.string(), + sourceCheckout: z.string(), + branch: z.string().min(1), + slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"), + path: z.string().min(1), +}; +const changeRequestFields = { + headRef: z.string().min(1), + headRepositoryOwner: z.string().optional(), + checkoutRefs: z + .array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() })) + .optional(), + pushRemoteUrl: z.string().optional(), + trackOriginHead: z.boolean().optional(), +}; +const worktreeRequestSchema = z.discriminatedUnion("kind", [ + z + .object({ ...worktreeRequestFields, kind: z.literal("branch-off"), baseRef: z.string().min(1) }) + .strict(), + z + .object({ + ...worktreeRequestFields, + kind: z.literal("checkout-branch"), + }) + .strict(), + z + .object({ + ...worktreeRequestFields, + ...changeRequestFields, + kind: z.literal("checkout-change-request"), + baseRef: z.string().min(1), + forge: z.string(), + changeRequestNumber: z.number().int().positive(), + headRepository: z.string().optional(), + }) + .strict(), + z + .object({ + ...worktreeRequestFields, + ...changeRequestFields, + kind: z.literal("checkout-github-pr"), + baseRef: z.string().min(1), + githubPrNumber: z.number().int().positive(), + }) + .strict(), +]); const beforeSchemas = { - "agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(), - "agent.session_open": z + "worktree.create.before": worktreeRequestSchema, + "agent.create.before": CreateAgentRequestMessageSchema.pick({ + config: true, + env: true, + }).strict(), + "agent.open.before": z .object({ agentId: z.string(), workspaceId: z.string().nullable(), @@ -62,10 +107,13 @@ const beforeSchemas = { mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), - "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), + "workspace.create.before": WorkspaceCreateRequestSchema.omit({ + type: true, + requestId: true, + }).strict(), }; -const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({ +const sessionOpenLocalResultSchema = beforeSchemas["agent.open.before"].extend({ tools: z.array( z.custom( (tool) => @@ -79,10 +127,11 @@ const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend( }); export interface PluginLifecycle { - emit( - name: Name, - event: PluginLifecycleEvents[Name], - ): void; + wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise; + emit(name: Name, event: PluginHookEvents[Name]): void; before( name: Name, request: PluginWireBeforeRequests[Name], @@ -130,32 +179,32 @@ export function publishAgentStream( event: AgentStreamEvent, ): void { if (event.type === "turn_started") { - lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null }); + lifecycle.emit("agent.turn.before", { agent, turnId: event.turnId ?? null }); } else if (event.type === "turn_completed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "completed" }, }); } else if (event.type === "turn_failed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "failed", error: { message: event.error, code: event.code } }, }); } else if (event.type === "turn_canceled") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "canceled", reason: event.reason }, }); } else if (event.type === "permission_requested") { - lifecycle.emit("agent.permission_requested", { agent, request: event.request }); + lifecycle.emit("agent.permission.before", { agent, request: event.request }); } else if (event.type === "permission_resolved") { - lifecycle.emit("agent.permission_resolved", { + lifecycle.emit("agent.permission.after", { agent, requestId: event.requestId, resolution: event.resolution, @@ -163,15 +212,15 @@ export function publishAgentStream( } } -export function validateBeforeResult( +export async function validateBeforeResult( name: Name, input: PluginWireBeforeRequests[Name], output: unknown, -): PluginWireBeforeRequests[Name] { +): Promise { const result = validateBeforeRequest(name, output); - if (name === "agent.session_open") { - const previous = beforeSchemas["agent.session_open"].parse(input); - const next = beforeSchemas["agent.session_open"].parse(result); + if (name === "agent.open.before") { + const previous = beforeSchemas["agent.open.before"].parse(input); + const next = beforeSchemas["agent.open.before"].parse(result); if ( previous.agentId !== next.agentId || previous.workspaceId !== next.workspaceId || @@ -182,14 +231,21 @@ export function validateBeforeResult unknown; export class PluginHookHandlers implements PluginLifecycleRegistration { - private readonly events = new Map>(); - private readonly transforms = new Map>(); + private readonly handlers = new Map>(); private readonly active = new Map(); private readonly changed: () => void; @@ -209,29 +264,24 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { constructor(changed: () => void, agentTools?: PluginAgentTools) { this.agentTools = agentTools; this.changed = changed; + const registration = createLifecycleRegistration((name, handler) => + this.register(name, handler as Handler), + ); + this.addHook = registration.addHook; + this.on = registration.on; + this.before = registration.before; } - readonly on: PluginLifecycleRegistration["on"] = (name, handler) => { - if (!lifecycleEventNames.includes(name)) { - throw new Error(`Unknown lifecycle event: ${name}`); - } - return this.register(this.events, name, handler as Handler); - }; + readonly addHook: PluginLifecycleRegistration["addHook"]; + readonly on: PluginLifecycleRegistration["on"]; + readonly before: PluginLifecycleRegistration["before"]; - readonly before: PluginLifecycleRegistration["before"] = (name, handler) => { - if (!beforeHookNames.includes(name)) { - throw new Error(`Unknown before hook: ${name}`); - } - return this.register(this.transforms, name, handler as Handler); - }; - - catalog(): { events: string[]; before: string[] } { - return { events: [...this.events.keys()], before: [...this.transforms.keys()] }; + catalog(): string[] { + return [...this.handlers.keys()]; } async invoke( id: string, - kind: "event" | "before", name: string, input: unknown, paseo: PluginHookContext["paseo"], @@ -239,34 +289,32 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { const controller = new AbortController(); this.active.set(id, controller); try { - if (kind === "before") { - if ( - !beforeHookNames.includes( - name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests, - ) - ) { - throw new Error(`Unknown before hook: ${name}`); - } - const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; - if (hookName === "agent.session_open") { + if (Object.hasOwn(beforeSchemas, name)) { + const hookName = name as keyof PluginHookRequests; + if (hookName === "agent.open.before") { return await this.openSession(input, { paseo, signal: controller.signal }); } let request = validateBeforeRequest(hookName, input); - for (const handler of this.transforms.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); const result = await handler( { request: structuredClone(request) }, { paseo, signal: controller.signal }, ); - if (result !== undefined) request = validateBeforeResult(hookName, request, result); + if (result !== undefined) request = await validateBeforeResult(hookName, request, result); } return request; } - for (const handler of this.events.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); try { await handler(structuredClone(input), { paseo, signal: controller.signal }); } catch (error) { + if ( + Object.hasOwn(pluginHookRegistry, name) && + pluginHookRegistry[name as keyof typeof pluginHookRegistry].waits + ) + throw error; console.error(`Lifecycle hook ${name} failed`, error); } } @@ -280,10 +328,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { input: unknown, context: PluginHookContext, ): Promise { - let request = validateBeforeRequest("agent.session_open", input); + let request = validateBeforeRequest("agent.open.before", input); this.agentTools?.detach(request.agentId); let tools: AgentToolDefinition[] = []; - for (const handler of this.transforms.get("agent.session_open") ?? []) { + for (const handler of this.handlers.get("agent.open.before") ?? []) { context.signal.throwIfAborted(); const result = await handler( { request: { ...structuredClone(request), tools: [...tools] } }, @@ -292,7 +340,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { if (result === undefined) continue; const local = sessionOpenLocalResultSchema.parse(result); const metadata = local.tools.map(serializeAgentTool); - request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata }); + request = await validateBeforeResult("agent.open.before", request, { + ...local, + tools: metadata, + }); tools = local.tools; } context.signal.throwIfAborted(); @@ -311,19 +362,14 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { for (const controller of this.active.values()) { controller.abort(); } - this.events.clear(); - this.transforms.clear(); + this.handlers.clear(); } - private register( - registry: Map>, - name: string, - handler: Handler, - ): () => void { - let handlers = registry.get(name); + private register(name: string, handler: Handler): () => void { + let handlers = this.handlers.get(name); if (!handlers) { handlers = new Set(); - registry.set(name, handlers); + this.handlers.set(name, handlers); } handlers.add(handler); this.changed(); @@ -332,7 +378,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { return; } if (handlers.size === 0) { - registry.delete(name); + this.handlers.delete(name); } this.changed(); }; diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index c3fa5dda26..e582387d48 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -55,7 +55,7 @@ export type PluginProcessRequest = providerId: string; options: ProviderCatalogOptions; } - | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } + | { type: "hook"; requestId: string; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } @@ -79,13 +79,13 @@ export type PluginProcessRequest = export type PluginProcessMessage = | { type: "settings.changed"; settingsId: string } - | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } } + | { type: "hooks.changed"; hooks: string[] } | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[]; usageSources?: PluginUsageSourceMetadata[]; - hooks?: { events: string[]; before: string[] }; + hooks?: string[]; } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } @@ -109,7 +109,7 @@ export type PluginProcessMessage = | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; -const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict(); +const hooksSchema = z.array(z.string()); const providerMetadataSchema = z .object({ @@ -211,7 +211,6 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis .object({ type: z.literal("hook"), requestId: z.string(), - kind: z.enum(["event", "before"]), name: z.string(), input: z.unknown(), }) diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index e93ded990e..a72e268e4f 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -288,6 +288,7 @@ export function createPluginWorker(options: { registerProvider, registerUsageSource, registerSettings, + addHook: hooks.addHook, on: hooks.on, before: hooks.before, }); @@ -563,7 +564,7 @@ export function createPluginWorker(options: { }); return; } - void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + void hooks.invoke(message.requestId, message.name, message.input, paseo).then( (output) => { return send({ type: "result", requestId: message.requestId, output }); }, diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 8004fd52bf..cfa3292567 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; @@ -2078,7 +2079,11 @@ const examples = (await readdir(examplesDirectory, { withFileTypes: true })) it.each(examples)("builds and loads the %s plugin example", async (example) => { const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); temporaryDirectories.push(settingsDirectory); - const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2"); + const runtime = createTestRuntime( + { settingsDirectory }, + undefined, + resolveDaemonVersion(import.meta.url), + ); try { await runtime.startPlugin(example, path.join(examplesDirectory, example)); expect(runtime.catalog()).toMatchObject([{ id: example }]); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 5a15020cef..31f313cb7f 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,5 @@ +import { validateWorktreePath } from "../../utils/worktree-path.js"; +import type { PluginHookPayload } from "@getpaseo/plugin/server"; import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; @@ -6,11 +8,7 @@ import { type ProviderStatus, type ProviderStatusRequest, } from "@getpaseo/plugin/server/provider"; -import type { - PluginLifecycleEvents, - PluginHookAgent, - AgentToolResult, -} from "@getpaseo/plugin/server"; +import type { PluginHookEvents, PluginHookAgent, AgentToolResult } from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; import path from "node:path"; @@ -77,7 +75,7 @@ interface LoadedPlugin { /** Lets a client that already holds this bundle skip downloading it again. */ clientBundleHash: string; methods: ReadonlySet; - hooks: { events: string[]; before: string[] }; + hooks: string[]; providers: readonly PluginProviderMetadata[]; usageSources: readonly PluginUsageSourceMetadata[]; child: PluginChild | null; @@ -311,7 +309,7 @@ export class PluginRuntime { } private notifySessionOpenChange(loaded: LoadedPlugin): void { - if (!loaded.hooks.before.includes("agent.session_open")) return; + if (!loaded.hooks.includes("agent.open.before")) return; for (const listener of this.sessionOpenListeners) listener(); } @@ -516,18 +514,14 @@ export class PluginRuntime { return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); } - emit( - name: Name, - event: PluginLifecycleEvents[Name], - ): void { + emit(name: Name, event: PluginHookEvents[Name]): void { for (const loaded of this.plugins.values()) { - if (!loaded.hooks.events.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } const request = this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "event", name, input: event, }) @@ -548,6 +542,25 @@ export class PluginRuntime { await Promise.all(this.pendingEvents); } + async wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise { + const plugins = [...this.plugins.values()].sort((left, right) => + left.id.localeCompare(right.id), + ); + for (const loaded of plugins) { + if (!loaded.hooks.includes(name)) continue; + try { + await this.request(loaded, { type: "hook", requestId: randomUUID(), name, input: payload }); + } catch (error) { + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { + cause: error, + }); + } + } + } + async before( name: Name, input: PluginWireBeforeRequests[Name], @@ -560,33 +573,38 @@ export class PluginRuntime { const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; const mcpServerOwners: Record = {}; for (const loaded of plugins) { - if (!loaded.hooks.before.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } try { const output = await this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "before", name, - input: - name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request, + input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request, }); - request = validateBeforeResult(name, request, output); - if (name === "agent.session_open") { + request = await validateBeforeResult(name, request, output); + if (name === "worktree.create.before") { + const worktree = request as PluginWireBeforeRequests["worktree.create.before"]; + worktree.path = await validateWorktreePath({ + candidate: worktree.path, + repoPath: worktree.repoRoot, + }); + } + if (name === "agent.open.before") { const opened = request as SessionOpenWireRequest; this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); this.collectAgentTools(attached, loaded, opened.tools); } } catch (error) { - if (name === "agent.session_open") + if (name === "agent.open.before") this.detachTools((request as SessionOpenWireRequest).agentId); - throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { cause: error, }); } } - if (name === "agent.session_open") { + if (name === "agent.open.before") { return { ...request, tools: attached, @@ -763,7 +781,7 @@ export class PluginRuntime { clientBundleHash: hashClientBundle(clientBundle), requirements: manifest.requirements, methods: new Set(), - hooks: { events: [], before: [] }, + hooks: [], providers: [], usageSources: [], child: null, @@ -893,7 +911,7 @@ export class PluginRuntime { clientBundleHash: hashClientBundle(clientBundle), requirements, methods: new Set(ready.methods), - hooks: ready.hooks ?? { events: [], before: [] }, + hooks: ready.hooks ?? [], providers: ready.providers ?? [], usageSources: ready.usageSources ?? [], child, @@ -973,11 +991,11 @@ export class PluginRuntime { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { if (message.type === "hooks.changed") { - const hadSessionOpen = loaded.hooks.before.includes("agent.session_open"); + const hadSessionOpen = loaded.hooks.includes("agent.open.before"); loaded.hooks = message.hooks; if ( this.plugins.get(loaded.id) === loaded && - hadSessionOpen !== loaded.hooks.before.includes("agent.session_open") + hadSessionOpen !== loaded.hooks.includes("agent.open.before") ) { for (const listener of this.sessionOpenListeners) listener(); } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 3cdef95a83..d1408f9880 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -8,7 +8,15 @@ import { createTestCreationService, } from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; -import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; import { tmpdir } from "os"; import { join, resolve as resolvePath } from "path"; import pino from "pino"; @@ -28,6 +36,11 @@ import { type FileTransferFrame, } from "@getpaseo/protocol/binary-frames/index"; import { Session } from "./session.js"; +import { + terminalCleanupRecordPath, + terminalWorkerDirectory, + writeTerminalCleanupRecord, +} from "../terminal/terminal-worker-record.js"; import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; @@ -2071,11 +2084,84 @@ describe("daemon status + pairing RPC", () => { { provider: "claude", available: true, error: null }, { provider: "codex", available: false, error: "boom" }, ], + terminalCleanupUnconfirmed: [], }, }, ]); }); + test("daemon.get_status.request reports what a terminal cleanup could not confirm gone", async () => { + const messages: unknown[] = []; + const paseoHome = makeHome(); + const unresolved = { + pid: 4242, + role: "terminal program" as const, + reason: "still alive" as const, + detail: "survived SIGKILL", + }; + await writeTerminalCleanupRecord(paseoHome, { + recordId: "closing-record", + pid: 4241, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: join(paseoHome, "closing.sock"), + secret: "closing-secret", + terminals: [{ id: "t1", pid: 4242, startedAt: "Thu Jan 1 00:00:00 1970" }], + closing: { unresolved: [unresolved] }, + }); + const session = createSessionForTest({ + messages, + paseoHome, + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, + agentManager: { listProviderAvailability: vi.fn().mockResolvedValue([]) }, + }); + + await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-2" }); + + expect(messages).toEqual([ + expect.objectContaining({ + type: "daemon.get_status.response", + payload: expect.objectContaining({ terminalCleanupUnconfirmed: [unresolved] }), + }), + ]); + }); + + test("daemon.get_status.request fails when a terminal cleanup record cannot be read", async () => { + const messages: unknown[] = []; + const paseoHome = makeHome(); + mkdirSync(terminalWorkerDirectory(paseoHome), { recursive: true }); + const cleanupPath = terminalCleanupRecordPath(paseoHome, "closing-record"); + writeFileSync(cleanupPath, "{ not json"); + const session = createSessionForTest({ + messages, + paseoHome, + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, + agentManager: { listProviderAvailability: vi.fn().mockResolvedValue([]) }, + }); + + await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-3" }); + + expect(messages).toContainEqual( + expect.objectContaining({ + type: "rpc_error", + payload: expect.objectContaining({ + requestId: "status-3", + error: expect.stringContaining(cleanupPath), + }), + }), + ); + expect(messages).not.toContainEqual( + expect.objectContaining({ type: "daemon.get_status.response" }), + ); + }); + test("daemon.get_status.request falls back to a null/empty status when provider listing fails", async () => { const messages: unknown[] = []; const session = createSessionForTest({ @@ -2107,6 +2193,7 @@ describe("daemon status + pairing RPC", () => { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed: [], }, }, ]); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b52c9dbfe4..3eac37cb7b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -485,6 +485,7 @@ export interface SessionOptions { workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { + wait: import("./plugins/lifecycle/index.js").PluginLifecycle["wait"]; before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; listPlugins(): Promise; @@ -576,12 +577,14 @@ export type SessionLifecycleIntent = clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } | { type: "restart"; clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }; function parseClientCapabilities( @@ -1104,6 +1107,7 @@ export class Session { "Terminal output": this.terminalController.getMetrics().streamSubscriptionCount, }), listProviderAvailability: () => this.agentManager.listProviderAvailability(), + getTerminalsUnavailable: () => terminalManager?.getUnavailableError?.()?.message ?? null, listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), listWorkspaces: () => this.workspaceRegistry.list(), @@ -2727,9 +2731,13 @@ export class Session { case "dictation_stream_cancel": return this.voiceSessions.handleMessage(msg); case "restart_server_request": - return this.handleRestartServerRequest(msg.requestId, msg.reason); + return this.handleRestartServerRequest(msg.requestId, msg.reason, { + keepTerminals: msg.killTerminals !== true, + }); case "shutdown_server_request": - return this.handleShutdownServerRequest(msg.requestId); + return this.handleShutdownServerRequest(msg.requestId, { + keepTerminals: msg.keepTerminals === true, + }); case "client_heartbeat": this.handleClientHeartbeat(msg); return undefined; @@ -3255,7 +3263,11 @@ export class Session { this.terminalController.handleBinaryFrame(binaryFrame.frame, source); } - private async handleRestartServerRequest(requestId: string, reason?: string): Promise { + private async handleRestartServerRequest( + requestId: string, + reason: string | undefined, + options: { keepTerminals: boolean }, + ): Promise { const lifecycleReason = normalizeClientRestartRpcReason(reason); const payload: { status: string } & Record = { status: "restart_requested", @@ -3266,7 +3278,10 @@ export class Session { } payload.requestId = requestId; - this.sessionLogger.warn({ reason: lifecycleReason }, "Restart requested via websocket"); + this.sessionLogger.warn( + { reason: lifecycleReason, keepTerminals: options.keepTerminals }, + "Restart requested via websocket", + ); this.emit({ type: "status", payload, @@ -3277,12 +3292,19 @@ export class Session { clientId: this.clientId, requestId, reason: lifecycleReason, + keepTerminals: options.keepTerminals, }); } - private async handleShutdownServerRequest(requestId: string): Promise { + private async handleShutdownServerRequest( + requestId: string, + options: { keepTerminals: boolean }, + ): Promise { const reason = CLIENT_SHUTDOWN_RPC_REASON; - this.sessionLogger.warn({ reason }, "Shutdown requested via websocket"); + this.sessionLogger.warn( + { reason, keepTerminals: options.keepTerminals }, + "Shutdown requested via websocket", + ); this.emit({ type: "status", payload: { @@ -3297,6 +3319,7 @@ export class Session { clientId: this.clientId, requestId, reason, + keepTerminals: options.keepTerminals, }); } @@ -5987,6 +6010,7 @@ export class Session { }, ): Promise { const result = await createPaseoWorktree(input, { + lifecycle: this.pluginRuntime, github: this.github, ...(options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } @@ -6795,7 +6819,7 @@ export class Session { // and must not rerun hooks or compare their potentially changing output. if (this.pluginRuntime) { const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); + const transformed = await this.pluginRuntime.before("workspace.create.before", input); creationRequest = { ...transformed, type, requestId }; } return creationRequest.source.kind === "directory" diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 3755ff56d8..9e73c9cad4 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5991,6 +5991,9 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t projectId, cwd: worktree.worktreePath, kind: "worktree", + worktreeRoot: worktree.worktreePath, + mainRepoRoot: repoDir, + isPaseoOwnedWorktree: true, displayName: "worktree-kind-archive", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts index 81c41f3421..e9bcf94b65 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -118,6 +118,7 @@ describe("DaemonSelfUpdateSessionController", () => { clientId: "client-1", requestId: "update-1", reason: "daemon_update", + keepTerminals: true, }, ]); }); diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts index 461a97277b..bb75f008bd 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts @@ -19,6 +19,7 @@ interface DaemonSelfUpdateRestartIntent { clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } export interface DaemonSelfUpdateSessionControllerOptions { @@ -88,6 +89,8 @@ export class DaemonSelfUpdateSessionController { clientId: this.clientId, requestId: msg.requestId, reason: "daemon_update", + // A self-update restart keeps terminals: the worker outlives the daemon. + keepTerminals: true, }); } catch (error) { if (error instanceof DaemonSelfUpdateInProgressError) { diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index e96256c4cf..cb6da8777b 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -212,6 +212,7 @@ describe("DaemonSession", () => { { provider: "claude", available: true, error: null }, { provider: "codex", available: false, error: "boom" }, ], + terminalCleanupUnconfirmed: [], }, }, ]); @@ -242,6 +243,7 @@ describe("DaemonSession", () => { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed: [], }, }, ]); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index ccc9e66e6d..585fa45573 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -2,6 +2,7 @@ import type pino from "pino"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { getPidLockInfo } from "../../pid-lock.js"; +import { readUnconfirmedTerminalCleanup } from "../../../terminal/terminal-worker-reaper.js"; import { generateLocalPairingOffer } from "../../pairing-offer.js"; import { collectDaemonDiagnostics, @@ -35,6 +36,7 @@ export interface DaemonSessionHost { clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }): void; } @@ -49,6 +51,8 @@ export interface DaemonSessionOptions { listProjects: () => Promise; listWorkspaces: () => Promise; listProviderAvailability: () => Promise; + /** Why this host's terminals cannot be served (held by a newer Paseo's worker), if so. */ + getTerminalsUnavailable?: () => string | null; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; getObservationMetrics?: () => Record; logger: pino.Logger; @@ -74,6 +78,7 @@ export class DaemonSession { private readonly listProjects: () => Promise; private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; + private readonly getTerminalsUnavailable: () => string | null; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; private readonly getObservationMetrics: DaemonSessionOptions["getObservationMetrics"]; private readonly logger: pino.Logger; @@ -92,6 +97,7 @@ export class DaemonSession { this.listProjects = options.listProjects; this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; + this.getTerminalsUnavailable = options.getTerminalsUnavailable ?? (() => null); this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.getObservationMetrics = options.getObservationMetrics; this.logger = options.logger; @@ -173,6 +179,12 @@ export class DaemonSession { async handleGetStatusRequest( msg: Extract, ): Promise { + // Read outside the degraded-status fallback below: a status that cannot say whether + // terminal cleanup finished must fail the request, not report that nothing is left. + const terminalCleanupUnconfirmed = (await readUnconfirmedTerminalCleanup(this.paseoHome)).map( + ({ pid, role, reason, detail }) => ({ pid, role, reason, detail }), + ); + const terminalsUnavailable = this.getTerminalsUnavailable(); try { const pidInfo = await getPidLockInfo(this.paseoHome); const providers = (await this.listProviderAvailability()).map((p) => ({ @@ -192,6 +204,8 @@ export class DaemonSession { listen: this.daemonRuntimeConfig?.listen ?? null, relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null, providers, + terminalCleanupUnconfirmed, + ...(terminalsUnavailable ? { terminalsUnavailable } : {}), }, }); } catch (error) { @@ -208,6 +222,8 @@ export class DaemonSession { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed, + ...(terminalsUnavailable ? { terminalsUnavailable } : {}), }, }); } diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0a3b8cb257..c93e535709 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -175,7 +175,7 @@ test("background workspace creation preserves hooks, inheritance and durable vis expect(child.background).toBe(true); expect(visible.background).toBe(false); expect(human.background).toBe(false); - expect(events).toEqual(Array(4).fill("workspace.created")); + expect(events).toEqual(Array(4).fill("workspace.create.after")); const reloaded = new FileBackedWorkspaceRegistry( path.join(tmpDir, "projects", "workspaces.json"), logger, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 3ac91a834a..08d7b93bf4 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -307,7 +307,9 @@ export function createWorkspaceProvisioningService(deps: { } function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + deps.lifecycle?.emit("workspace.create.after", { + workspace: describeHookWorkspace(workspace), + }); } async function resolveSourceProjectForWorktree(input: { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 22e903b5f1..5526166d05 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, + readFileSync, rmSync, statSync, writeFileSync, @@ -476,3 +477,43 @@ test("restore rejects a branch checked out elsewhere without inventing another b }).trim(), ).toBe("* feature"); }); + +test("restores a user-created worktree at its recorded path outside Paseo's root", async () => { + const { tempDir, repoDir } = createGitRepository(); + const worktreeRoot = join(realpathSync(tempDir), "external-worktree"); + execFileSync("git", ["worktree", "add", "-b", "external-feature", worktreeRoot], { + cwd: repoDir, + stdio: "pipe", + }); + const workspace = createWorkspace({ + cwd: worktreeRoot, + worktreeRoot, + mainRepoRoot: repoDir, + branch: "external-feature", + baseBranch: "main", + isPaseoOwnedWorktree: false, + }); + execFileSync("git", ["worktree", "remove", worktreeRoot], { cwd: repoDir, stdio: "pipe" }); + const unarchived: string[] = []; + const service = createWorkspaceRecoveryService({ + paseoHome: join(tempDir, "paseo-home"), + getWorkspace: async () => workspace, + getProject: async () => createProject({ rootPath: repoDir }), + isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(), + unarchiveWorkspace: async (record) => { + unarchived.push(record.workspaceId); + }, + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "restore", + }); + expect(readFileSync(join(worktreeRoot, "README.md"), "utf8").trim()).toBe("initial"); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: worktreeRoot, + encoding: "utf8", + }).trim(), + ).toBe("external-feature"); + expect(unarchived).toEqual([workspace.workspaceId]); +}); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1a715dd349..a683a07744 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -166,6 +166,7 @@ export function createWorkspaceRecoveryService(deps: { } let previousWorktreePath = workspace.worktreeRoot; + let hasRecordedWorktreePath = Boolean(previousWorktreePath); if (!previousWorktreePath) { // COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110 // lack durable backing placement; remove filesystem discovery after 2027-01-17. @@ -176,6 +177,7 @@ export function createWorkspaceRecoveryService(deps: { previousWorktreePath = ownership.allowed ? (ownership.worktreePath ?? workspace.cwd) : workspace.cwd; + hasRecordedWorktreePath = ownership.allowed; } let recreatedWorktreePath: string; @@ -187,6 +189,7 @@ export function createWorkspaceRecoveryService(deps: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, + ...(hasRecordedWorktreePath ? { worktreePath: previousWorktreePath } : {}), }); recreatedWorktreePath = result.worktreePath; } catch (error) { diff --git a/packages/server/src/server/test-utils/paseo-daemon.test.ts b/packages/server/src/server/test-utils/paseo-daemon.test.ts index 4ee417c221..3fbb0b5232 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.test.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.test.ts @@ -45,7 +45,15 @@ describe("createTestPaseoDaemon", () => { createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }), ).rejects.toMatchObject({ code: "EADDRINUSE" }); - expect(await readdir(scratchTmp)).toEqual([]); + // A home under this scratch dir is too long for a unix socket path, so the terminal worker + // listens in the per-user `paseo-` directory of the temp root. Like tmux's, that + // owner-only directory is shared by every worker and outlives them; it must be empty. + const sharedSocketDir = `paseo-${process.getuid?.() ?? 0}`; + const leftovers = await readdir(scratchTmp); + expect(leftovers.filter((name) => name !== sharedSocketDir)).toEqual([]); + if (leftovers.includes(sharedSocketDir)) { + expect(await readdir(path.join(scratchTmp, sharedSocketDir))).toEqual([]); + } }, 60_000); }); diff --git a/packages/server/src/server/watcher-liveness-canary.test.ts b/packages/server/src/server/watcher-liveness-canary.test.ts index 4da1481408..7e719da9f0 100644 --- a/packages/server/src/server/watcher-liveness-canary.test.ts +++ b/packages/server/src/server/watcher-liveness-canary.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { watchFile, unwatchFile } from "node:fs"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; @@ -31,3 +32,39 @@ test("rejects when the watcher never reports the canary", async () => { await expect(canary.verify()).rejects.toThrow("did not report its liveness canary"); }); + +test("verifies a watcher that starts delivering after the initial canary write", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot); + const verification = canary.verify(); + const verified = expect(verification).resolves.toBeUndefined(); + + // The initial write happens before the observer starts delivering changes. + await expect.poll(() => readFile(canary.path, "utf8")).toBe("paseo watcher liveness canary\n"); + watchFile(canary.path, { interval: 10 }, () => { + canary.filterEvents([{ path: canary.path, type: "update" }]); + }); + try { + await verified; + await expect(readFile(canary.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + unwatchFile(canary.path); + } +}); + +test("aborting verification stops pulses before removing the canary", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot); + const controller = new AbortController(); + const verification = canary.verify(controller.signal); + const cancelled = expect(verification).rejects.toThrow("stop watching"); + await expect.poll(() => readFile(canary.path, "utf8")).toBe("paseo watcher liveness canary\n"); + const initialWrite = (await stat(canary.path)).mtimeMs; + await expect.poll(async () => (await stat(canary.path)).mtimeMs).toBeGreaterThan(initialWrite); + + controller.abort(new Error("stop watching")); + await cancelled; + await expect(readFile(canary.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/packages/server/src/server/watcher-liveness-canary.ts b/packages/server/src/server/watcher-liveness-canary.ts index a366f0229a..2fc5c5dbdf 100644 --- a/packages/server/src/server/watcher-liveness-canary.ts +++ b/packages/server/src/server/watcher-liveness-canary.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { setInterval } from "node:timers/promises"; import type { FileChange } from "./file-observer/index.js"; export const WATCHER_LIVENESS_CANARY_TIMEOUT_MS = 10_000; +const CANARY_CONTENTS = "paseo watcher liveness canary\n"; export interface WatcherLivenessCanary { readonly path: string; @@ -32,7 +34,11 @@ export function createWatcherLivenessCanary( return filtered; }, async verify(signal) { - await writeFile(canaryPath, "paseo watcher liveness canary\n", { flag: "wx" }); + await writeFile(canaryPath, CANARY_CONTENTS, { flag: "wx" }); + // Native registration can return before macOS starts its FSEvents stream. + // Keep probing until an event round-trips instead of relying on the first write. + const pulses = new AbortController(); + const pulsing = pulseCanary(canaryPath, pulses.signal); let timeout: NodeJS.Timeout | null = null; let removeAbortListener = () => {}; try { @@ -55,12 +61,24 @@ export function createWatcherLivenessCanary( signal.addEventListener("abort", rejectForAbort, { once: true }); removeAbortListener = () => signal.removeEventListener("abort", rejectForAbort); }); - await Promise.race([reported, timeoutPromise, abortPromise]); + await Promise.race([reported, timeoutPromise, abortPromise, pulsing]); } finally { if (timeout) clearTimeout(timeout); removeAbortListener(); + pulses.abort(); + await Promise.allSettled([pulsing]); await rm(canaryPath, { force: true }); } }, }; } + +async function pulseCanary(path: string, signal: AbortSignal): Promise { + try { + for await (const _ of setInterval(100, undefined, { signal })) { + await writeFile(path, CANARY_CONTENTS); + } + } catch (error) { + if (!signal.aborted) throw error; + } +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index dfa324315c..6705c1cdd9 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2054,6 +2054,8 @@ export class VoiceAssistantWebSocketServer { daemonDiagnostics: true, // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: this.daemonRuntimeConfig?.desktopManaged !== true, + // COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. + terminalsSurviveRestart: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index a1a901c4d6..567c80054f 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -7,7 +7,9 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; -import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; +import { getPaseoWorktreeMetadataPath } from "../utils/worktree-metadata.js"; +import { archiveCommand } from "./worktree/commands.js"; +import { createWorktree, listPaseoWorktrees, type WorktreeConfig } from "../utils/worktree.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -190,6 +192,54 @@ function assertArchiveResult( } describe("archiveByScope", () => { + test.each(["workspace", "worktree"] as const)( + "%s archive preserves a restored external worktree despite its marker", + async (kind) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const recordedPath = path.join(tempDir, "external"); + execFileSync("git", ["worktree", "add", "-b", "external", recordedPath], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["worktree", "remove", recordedPath], { cwd: repoDir, stdio: "pipe" }); + const restored = await createWorktree({ + cwd: repoDir, + paseoHome, + worktreeSlug: "external", + worktreePath: recordedPath, + source: { kind: "restore", branchName: "external", baseRef: "main" }, + }); + expect(existsSync(await getPaseoWorktreeMetadataPath(restored.worktreePath))).toBe(true); + const workspaceId = "external-workspace"; + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: restored.worktreePath, + kind: "worktree", + worktreeRoot: restored.worktreePath, + mainRepoRoot: repoDir, + isPaseoOwnedWorktree: false, + }, + ], + }), + { + scope: + kind === "workspace" + ? { kind, workspaceId } + : { kind, targetPath: restored.worktreePath }, + requestId: "archive-external", + }, + ); + expect(result.removedDirectory).toBe(false); + expect(existsSync(restored.worktreePath)).toBe(true); + expect(result.archivedWorkspaceIds).toEqual([workspaceId]); + }, + ); + test("workspace scope archives the record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -220,6 +270,120 @@ describe("archiveByScope", () => { expect(existsSync(worktree.worktreePath)).toBe(false); }); + test("workspace scope archives the record but leaves a plugin-placed directory whose marker is gone", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createWorktree({ + cwd: repoDir, + worktreeSlug: "unproven-workspace", + source: { kind: "branch-off", baseBranch: "main", branchName: "unproven-workspace" }, + paseoHome, + worktreePath: path.join(tempDir, "placed-worktree"), + }); + // Remove the ownership marker: no proof remains for this path. + rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true }); + const workspaceId = "ws-unproven"; + + const logs: Array<{ targetPath: string; msg: string }> = []; + const dependencies = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + dependencies.sessionLogger = pino( + { level: "warn" }, + { + write(message) { + logs.push(JSON.parse(message)); + }, + }, + ); + const result = await archiveByScope(dependencies, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-unproven-workspace", + }); + + assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: false }); + expect(result.directoryLeftBehind?.path).toBe(worktree.worktreePath); + expect(result.directoryLeftBehind?.reason).toContain("no ownership proof"); + expect(existsSync(worktree.worktreePath)).toBe(true); + expect(logs).toContainEqual( + expect.objectContaining({ + targetPath: worktree.worktreePath, + msg: expect.stringContaining("no ownership proof"), + }), + ); + }); + + test("archives a plugin-placed worktree by its discovered directory name", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = path.join(tempDir, "sibling-feature"); + await createWorktree({ + cwd: repoDir, + paseoHome, + worktreePath, + worktreeSlug: "original-slug", + source: { kind: "branch-off", branchName: "feature", baseBranch: "main" }, + }); + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const result = await archiveCommand( + { + ...dependencies, + workspaceGitService: { + ...dependencies.workspaceGitService, + listWorktrees: (cwd) => listPaseoWorktrees({ cwd, paseoHome }), + }, + }, + { + repoRoot: repoDir, + worktreeSlug: "sibling-feature", + scope: "worktree", + requestId: "slug-archive", + }, + ); + expect(result).toEqual({ ok: true, removedAgents: [] }); + expect(existsSync(worktreePath)).toBe(false); + }); + + test("workspace scope deletes a plugin-placed directory on registry proof even with the marker gone", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createWorktree({ + cwd: repoDir, + worktreeSlug: "registry-proven-workspace", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "registry-proven-workspace", + }, + paseoHome, + worktreePath: path.join(tempDir, "placed-worktree"), + }); + rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true }); + const workspaceId = "ws-registry-proven"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + isPaseoOwnedWorktree: true, + worktreeRoot: worktree.worktreePath, + mainRepoRoot: repoDir, + }, + ], + }), + { scope: { kind: "workspace", workspaceId }, requestId: "req-registry-proven-workspace" }, + ); + + assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: true }); + expect(result.directoryLeftBehind).toBeUndefined(); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => { const { tempDir, repoDir } = createGitRepo(); writeFileSync( diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index e9e58681a1..78f8c0b97d 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -69,6 +69,10 @@ export interface ArchiveResult { archivedAgentIds: string[]; archivedWorkspaceIds: string[]; removedDirectory: boolean; + // Set when the record archived but its worktree directory was left on + // disk because no ownership proof held for it. The directory is never + // deleted in that case; the path and reason surface here and in the log. + directoryLeftBehind?: { path: string; reason: string }; } export interface ArchiveByScopeRequest { @@ -92,6 +96,15 @@ export async function requireActiveWorkspaceForArchive( interface BackingDirectory { path: string; isPaseoOwnedWorktree: boolean; + // True when the directory backs a worktree workspace (or a worktree-scope + // archive), so a failed ownership proof means a Paseo worktree directory + // is being left behind rather than a user's local checkout being ignored. + expectsPaseoOwnership: boolean; + // True only when this backing came from a workspace registry record + // matched to this path (the record is itself an ownership proof, passed + // through to deletePaseoWorktree). Disk-derived backings stay false: + // they must prove ownership on disk. + registryProven: boolean; mainRepoRoot: string | null; paseoWorktreesRoot: string | null; } @@ -141,6 +154,7 @@ async function archiveByScopeWithPriority( } let removedDirectory = false; + let directoryLeftBehind: ArchiveResult["directoryLeftBehind"]; try { if (targetWorkspaceIds.length > 0) { @@ -168,18 +182,21 @@ async function archiveByScopeWithPriority( } if (target.backing !== null) { - removedDirectory = await maybeRemoveDirectory( + const removal = await maybeRemoveDirectory( dependencies, request, target, archivedWorkspaceIds, ); + removedDirectory = removal.removedDirectory; + directoryLeftBehind = removal.directoryLeftBehind; } return { archivedAgentIds: Array.from(archivedAgents), archivedWorkspaceIds, removedDirectory, + ...(directoryLeftBehind ? { directoryLeftBehind } : {}), }; } finally { if (targetWorkspaceIds.length > 0) { @@ -226,14 +243,16 @@ async function resolveArchiveTarget( }), ) ).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null); - const persistedMainRepoRoot = targetWorkspaces.find( - (workspace) => workspace.mainRepoRoot, - )?.mainRepoRoot; + const recordedId = await dependencies.findWorkspaceIdForCwd(backing.path); + const recorded = recordedId ? await dependencies.getWorkspace?.(recordedId) : null; + const record = + targetWorkspaces.find((workspace) => workspace.kind === "worktree") ?? + (recorded && matchesBackingDirectory(recorded.worktreeRoot ?? recorded.cwd) + ? recorded + : null) ?? + targetWorkspaces[0]; return { - backing: { - ...backing, - mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, - }, + backing: record ? await resolveWorkspaceBackingDirectory(record, dependencies) : backing, teardownTargets: targetWorkspaces.length > 0 ? targetWorkspaces.map((workspace) => ({ @@ -271,10 +290,22 @@ async function resolveWorkspaceBackingDirectory( workspace: ActiveWorkspaceRef, dependencies: Pick, ): Promise { + if (workspace.isPaseoOwnedWorktree === false) { + return { + path: resolve(workspace.worktreeRoot ?? workspace.cwd), + isPaseoOwnedWorktree: false, + expectsPaseoOwnership: false, + registryProven: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { return { path: resolve(workspace.worktreeRoot), isPaseoOwnedWorktree: true, + expectsPaseoOwnership: true, + registryProven: true, mainRepoRoot: workspace.mainRepoRoot, paseoWorktreesRoot: null, }; @@ -283,6 +314,8 @@ async function resolveWorkspaceBackingDirectory( return { path: resolve(workspace.cwd), isPaseoOwnedWorktree: false, + expectsPaseoOwnership: false, + registryProven: false, mainRepoRoot: workspace.mainRepoRoot ?? null, paseoWorktreesRoot: null, }; @@ -309,6 +342,8 @@ async function resolveBackingDirectory( return { path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd), isPaseoOwnedWorktree: ownership.allowed, + expectsPaseoOwnership: true, + registryProven: false, mainRepoRoot: ownership.repoRoot ?? null, paseoWorktreesRoot: ownership.worktreeRoot ?? null, }; @@ -352,10 +387,28 @@ async function maybeRemoveDirectory( request: Pick, target: ArchiveTarget, archivedWorkspaceIds: string[], -): Promise { +): Promise<{ + removedDirectory: boolean; + directoryLeftBehind?: { path: string; reason: string }; +}> { const backing = target.backing; if (!backing?.isPaseoOwnedWorktree) { - return false; + // Missing ownership proof for a worktree-backed directory: the record + // still archives, but the directory is left in place and surfaced. + // Never delete an unproven directory. + if (backing?.expectsPaseoOwnership) { + const reason = + "no ownership proof (not under the Paseo worktrees root, no paseo/worktree.json marker, no registry record)"; + dependencies.sessionLogger?.warn( + { targetPath: backing.path, requestId: request.requestId }, + `Worktree directory left in place during archive: ${reason}`, + ); + return { + removedDirectory: false, + directoryLeftBehind: { path: backing.path, reason }, + }; + } + return { removedDirectory: false }; } const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds); @@ -382,7 +435,7 @@ async function maybeRemoveDirectory( { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree teardown failed during archive; workspace already archived", ); - return false; + return { removedDirectory: false }; } throw error; } @@ -396,7 +449,7 @@ async function maybeRemoveDirectory( dependencies, )) ) { - return false; + return { removedDirectory: false }; } try { @@ -407,16 +460,17 @@ async function maybeRemoveDirectory( worktreesRoot: backing.paseoWorktreesRoot ?? undefined, paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, + registryProven: backing.registryProven, }); dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); - return true; + return { removedDirectory: true }; } catch (error) { dependencies.sessionLogger?.warn( { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree disk removal failed during archive; workspace already archived", ); - return false; + return { removedDirectory: false }; } } diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 9f53dec33a..a2cb27739d 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -12,12 +12,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js"; function createLogger(): pino.Logger { - const logger = { - child: () => logger, - debug: vi.fn(), - warn: vi.fn(), - }; - return logger as unknown as pino.Logger; + return pino({ level: "warn" }); } function createFacts(cwd: string): CheckoutSnapshotFacts { @@ -232,7 +227,14 @@ test("recursive observation updates tracked state and prunes ignored storms", as await vi.waitFor( () => { - expect(activeWatcherCount).toBe(2); + expect( + activeWatcherCount, + JSON.stringify({ + observer: observer.getDiagnostics(), + service: service.getMetrics(), + deliveredEvents, + }), + ).toBe(2); expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull(); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index b3fab7b89a..f3571f9932 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -796,17 +796,21 @@ describe("WorkspaceGitServiceImpl", () => { const service = createService({ getCheckoutStatus, subscribe, runGitCommand }); cwds.forEach((cwd) => service.registerWorkspace({ cwd }, vi.fn())); - await vi.waitFor(() => { - // Admission precedes async path resolution; disposal must race pending subscriptions. - expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY); - expect(service.getMetrics()).toMatchObject({ - workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY, - workspaceRefreshAdmissionPendingCount: 1, - workspaceObservationSetupAdmissionActiveCount: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, - workspaceObservationSetupAdmissionPendingCount: - cwds.length - WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, - }); - }); + await vi.waitFor( + () => { + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY, + workspaceRefreshAdmissionPendingCount: 1, + workspaceObservationSetupAdmissionActiveCount: + WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, + workspaceObservationSetupAdmissionPendingCount: + cwds.length - WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, + }); + // Admission precedes asynchronous path setup; dispose only once watchers are pending. + expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY); + }, + { timeout: 10_000 }, + ); const queuedRefreshSettlement = service.getSnapshot(cwds.at(-1)!).then( () => ({ status: "fulfilled" as const, error: null }), (error: unknown) => ({ status: "rejected" as const, error }), diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 4ec533d0c3..7a7a342732 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -24,6 +24,7 @@ import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; +import { validateBeforeRequest, validateBeforeResult } from "./plugins/lifecycle/index.js"; import { UnknownBranchError } from "../utils/worktree.js"; import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -419,6 +420,21 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(existsSync(result.worktree.worktreePath)).toBe(true); }); + test("keeps default placement suffixes when the default destination already exists", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + const first = await createCoreWorktree( + { cwd: repoDir, paseoHome, branchName: "first", worktreeSlug: "same" }, + createCoreDeps(), + ); + const second = await createCoreWorktree( + { cwd: repoDir, paseoHome, branchName: "second", worktreeSlug: "same" }, + createCoreDeps(), + ); + expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath + "-1"); + expect(existsSync(first.worktree.worktreePath)).toBe(true); + }); + test("creates branch-off worktrees from origin main without tracking origin main", async () => { const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginMain(); cleanupPaths.push(tempDir); @@ -488,6 +504,42 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(result.worktree.branchName).toBe("feature/review-pr"); }); + test("round trips every resolved change-request field through an unchanged before-hook result", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const deps = createCoreDeps(); + const requests: unknown[] = []; + const result = await createCoreWorktree( + { cwd: repoDir, githubPrNumber: 123, refName: "feature/review-pr", paseoHome }, + { + ...deps, + lifecycle: { + before: async (name, request) => { + requests.push(request); + return validateBeforeResult(name, validateBeforeRequest(name, request), request); + }, + }, + }, + ); + expect(requests).toEqual([ + { + kind: "checkout-change-request", + forge: "github", + changeRequestNumber: 123, + headRef: "feature/review-pr", + baseRef: "main", + branch: "feature/review-pr", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }], + trackOriginHead: true, + repoRoot: repoDir, + sourceCheckout: repoDir, + slug: "feature-review-pr", + path: result.worktree.worktreePath, + }, + ]); + expect(result.worktree.branchName).toBe("feature/review-pr"); + }); + test("uses the PR head ref as the default slug when no slug is supplied", async () => { const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); cleanupPaths.push(tempDir); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index b0310d6e1f..cc6c9fdbd6 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -1,8 +1,11 @@ +import type { PluginWorktreeCreateRequest } from "@getpaseo/plugin/server"; +import type { PluginLifecycle } from "./plugins/lifecycle/index.js"; import { createNameId } from "mnemonic-id"; import type { ForgeService } from "../services/forge-service.js"; import { createWorktree, + resolveDefaultWorktreePath, slugify, validateBranchSlug, type CreatedWorktree, @@ -15,7 +18,7 @@ import { } from "./resolve-worktree-creation-intent.js"; import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { runWithGitCommandPriority } from "../utils/run-git-command.js"; +import { runGitCommand, runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreateWorktreeCoreInput { cwd: string; @@ -31,6 +34,7 @@ export interface CreateWorktreeCoreInput { } export interface CreateWorktreeCoreDeps { + lifecycle?: Pick; github: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, @@ -90,7 +94,7 @@ async function createWorktreeCoreWithPriority( } const forge = await resolveForge(repoRoot, deps, intentInput); - const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, { + let intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, { forge: forge.forge, forgeService: forge.service, resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps), @@ -114,10 +118,33 @@ async function createWorktreeCoreWithPriority( } } + const defaultPath = await resolveDefaultWorktreePath({ + cwd: repoRoot, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + worktreeSlug: normalizedSlug, + }); + const { stdout: sourceRoot } = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: input.cwd, + }); + const sourceCheckout = sourceRoot.trim(); + const request = describeCreationRequest(intent, { + repoRoot, + sourceCheckout, + slug: normalizedSlug, + path: defaultPath, + }); + const transformed = deps.lifecycle + ? await deps.lifecycle.before("worktree.create.before", request) + : request; + normalizedSlug = validateWorktreeSlug(transformed.slug); + intent = creationIntentFromRequest(transformed, intent); + return { worktree: await createWorktree({ cwd: repoRoot, worktreeSlug: normalizedSlug, + worktreePath: transformed.path, source: intent, paseoHome: input.paseoHome, worktreesRoot: input.worktreesRoot, @@ -128,6 +155,61 @@ async function createWorktreeCoreWithPriority( }; } +function describeCreationRequest( + intent: WorktreeCreationIntent, + placement: Pick, +): PluginWorktreeCreateRequest { + if (intent.kind === "branch-off") + return { + ...placement, + kind: intent.kind, + branch: intent.branchName, + baseRef: intent.baseBranch, + }; + if (intent.kind === "checkout-branch") + return { ...placement, kind: intent.kind, branch: intent.branchName }; + const { localBranchName, baseRefName, ...facts } = intent; + return { + ...placement, + ...facts, + branch: localBranchName ?? intent.headRef, + baseRef: baseRefName, + }; +} + +function creationIntentFromRequest( + request: PluginWorktreeCreateRequest, + original: WorktreeCreationIntent, +): WorktreeCreationIntent { + if (request.kind === "branch-off") { + return { kind: request.kind, branchName: request.branch, baseBranch: request.baseRef }; + } + if (request.kind === "checkout-branch") { + return { + kind: request.kind, + branchName: request.branch, + }; + } + const { + repoRoot: _repoRoot, + sourceCheckout: _checkout, + slug: _slug, + path: _path, + branch, + baseRef, + ...facts + } = request; + const keepLocalBranchName = + branch !== request.headRef || + ((original.kind === "checkout-change-request" || original.kind === "checkout-github-pr") && + original.localBranchName !== undefined); + return { + ...facts, + ...(keepLocalBranchName ? { localBranchName: branch } : {}), + baseRefName: baseRef, + }; +} + async function resolveForge( repoRoot: string, deps: CreateWorktreeCoreDeps, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 0aab346984..4382e46633 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -1,6 +1,6 @@ -import { join } from "node:path"; +import { basename } from "node:path"; -import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { archiveByScope, resolveWorkspaceIdAtPath, @@ -208,10 +208,18 @@ async function resolveWorktreeSlugPath( repoRoot: string, worktreeSlug: string, ): Promise { - const worktreesRoot = await getPaseoWorktreesRoot( - repoRoot, - dependencies.paseoHome, - dependencies.paseoWorktreesBaseRoot, - ); - return join(worktreesRoot, worktreeSlug); + // Worktrees can live anywhere (legacy root or wherever + // a plugin placed them), so a slug resolves against the + // discovered worktree list for the repo, never a guessed path. + const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot); + const match = worktrees.find((entry) => basename(entry.path) === worktreeSlug); + if (!match) { + const found = worktrees.map((entry) => basename(entry.path)); + throw new Error( + found.length > 0 + ? `Paseo worktree not found for slug ${worktreeSlug}: found ${found.join(", ")}` + : `Paseo worktree not found for slug ${worktreeSlug}`, + ); + } + return match.path; } diff --git a/packages/server/src/terminal/paseo-cli-path.ts b/packages/server/src/terminal/paseo-cli-path.ts new file mode 100644 index 0000000000..18dff56f1b --- /dev/null +++ b/packages/server/src/terminal/paseo-cli-path.ts @@ -0,0 +1,83 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve as resolvePath } from "node:path"; + +// Where the Paseo CLI lives, for the PATH entry and PASEO_HOOK_CLI every terminal gets. Kept +// apart from terminal.ts so the daemon can resolve it without loading node-pty. + +const require = createRequire(import.meta.url); +const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo"; + +export function resolveExternalProcessPath(filePath: string): string { + return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked"); +} + +export function resolvePaseoCliBinDir(): string | null { + const cliExecutable = resolvePaseoCliExecutablePath(); + return cliExecutable ? dirname(cliExecutable) : null; +} + +export function resolvePaseoCliExecutablePath(): string | null { + const configuredCli = process.env.PASEO_CLI?.trim(); + if (configuredCli) { + return resolvePath(configuredCli); + } + + const cliEntrypoint = resolvePaseoCliBinEntrypoint(); + if (!cliEntrypoint) { + return null; + } + + const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); + const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint)); + if (npmBinDir) { + const shim = resolvePaseoCliShim(npmBinDir); + if (shim) { + return shim; + } + } + + return externalCliEntrypoint; +} + +function resolvePaseoCliBinEntrypoint(): string | null { + try { + return require.resolve(PASEO_CLI_BIN_ENTRY); + } catch { + return null; + } +} + +function findNpmBinDir(startPath: string): string | null { + let current = startPath; + while (true) { + const candidate = join(current, "node_modules", ".bin"); + if (hasPaseoCliShim(candidate)) { + return candidate; + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function hasPaseoCliShim(binDir: string): boolean { + return resolvePaseoCliShim(binDir) !== null; +} + +function resolvePaseoCliShim(binDir: string): string | null { + for (const name of paseoCliShimNames()) { + const candidate = join(binDir, name); + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function paseoCliShimNames(): string[] { + return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"]; +} diff --git a/packages/server/src/terminal/terminal-manager-factory.ts b/packages/server/src/terminal/terminal-manager-factory.ts deleted file mode 100644 index 88dcc2d692..0000000000 --- a/packages/server/src/terminal/terminal-manager-factory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TerminalManager } from "./terminal-manager.js"; -import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; - -export interface ConfiguredTerminalManagerOptions { - getTerminalActivityUrl?: () => string | null; -} - -export function createConfiguredTerminalManager( - options: ConfiguredTerminalManagerOptions = {}, -): TerminalManager { - return createWorkerTerminalManager(options); -} diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index df4ced389b..86b4cdb662 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,5 +1,6 @@ import { createTerminal, + type LocalTerminalSession, type TerminalActivityTransition, type TerminalSession, type TerminalStateSnapshot, @@ -50,6 +51,11 @@ export type TerminalWorkspaceContributionChangedListener = ( ) => void; export interface TerminalManager { + /** + * Set when this host's terminals cannot be served at all (a worker from a newer Paseo holds + * them). Callers that answer terminal requests report it instead of an empty list. + */ + getUnavailableError?(): Error | null; getTerminals(cwd: string, options?: { workspaceId?: string }): Promise; createTerminal(options: { id?: string; @@ -64,6 +70,8 @@ export interface TerminalManager { cols?: number; activityToken?: string; activityUrl?: string | null; + baseEnv?: Record; + paseoCliPath?: string | null; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; @@ -97,15 +105,21 @@ export interface TerminalManagerOptions { getTerminalActivityUrl?: () => string | null; } +/** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */ +export interface LocalTerminalManager extends TerminalManager { + getTerminalProcessId(id: string): number | null; + getTerminalActivityToken(id: string): string | null; +} + function createActivityToken(): string { return randomBytes(32).toString("base64url"); } export function createTerminalManager( managerOptions: TerminalManagerOptions = {}, -): TerminalManager { +): LocalTerminalManager { const terminalsByCwd = new Map(); - const terminalsById = new Map(); + const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalTitleUnsubscribeById = new Map void>(); const terminalActivityUnsubscribeById = new Map void>(); @@ -186,7 +200,7 @@ export function createTerminalManager( return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined; } - function registerSession(session: TerminalSession): TerminalSession { + function registerSession(session: LocalTerminalSession): LocalTerminalSession { terminalsById.set(session.id, session); const unsubscribeExit = session.onExit(() => { removeSessionById(session.id, { kill: false }); @@ -321,6 +335,8 @@ export function createTerminalManager( cols?: number; activityToken?: string; activityUrl?: string | null; + baseEnv?: Record; + paseoCliPath?: string | null; }): Promise { assertAbsolutePath(options.cwd); @@ -355,6 +371,8 @@ export function createTerminalManager( ...(options.rows !== undefined ? { rows: options.rows } : {}), ...(options.cols !== undefined ? { cols: options.cols } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), + ...(options.baseEnv ? { baseEnv: options.baseEnv } : {}), + ...(options.paseoCliPath === undefined ? {} : { paseoCliPath: options.paseoCliPath }), activityEnv, }), ); @@ -390,6 +408,14 @@ export function createTerminalManager( return terminalsById.get(id); }, + getTerminalProcessId(id: string): number | null { + return terminalsById.get(id)?.pid ?? null; + }, + + getTerminalActivityToken(id: string): string | null { + return terminalActivityTokenById.get(id) ?? null; + }, + async getTerminalState( id: string, options?: TerminalStateSnapshotOptions, diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index b438af8381..27f69cb44d 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -17,6 +17,7 @@ import { SessionDelivery } from "../server/session/owned-subscriptions/index.js" import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { isSameOrDescendantPath } from "../server/path-utils.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js"; +import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; function createController( options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, @@ -347,6 +348,76 @@ describe("terminal-session-controller legacy terminal creation", () => { }); }); +describe("terminal-session-controller with terminals held by a newer Paseo", () => { + const incompatible = new TerminalWorkerIncompatibleError({ + paseoVersion: "9.9.9", + protocolVersion: 7, + }); + + function incompatibleManager(): TerminalManager { + return { + getUnavailableError: () => incompatible, + getTerminals: vi.fn(async () => Promise.reject(incompatible)), + createTerminal: vi.fn(async () => Promise.reject(incompatible)), + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + // The incompatible worker's terminals are not in this daemon's view. + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + } + + test.each([ + { type: "list_terminals_request" as const, requestId: "req-list" }, + { type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" }, + { + type: "create_terminal_request" as const, + requestId: "req-create", + cwd: "/work/repo", + workspaceId: "ws-1", + }, + ])( + "answers $type ($requestId) with a terminal_worker_incompatible rpc_error", + async (request) => { + const outboundMessages: SessionOutboundMessage[] = []; + const controller = createController({ + terminalManager: incompatibleManager(), + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [{ workspaceId: "ws-1", cwd: "/work/repo" }], + }); + + await controller.dispatch(request); + + expect(outboundMessages).toEqual([ + { + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: incompatible.message, + code: "terminal_worker_incompatible", + }, + }, + ]); + }, + ); +}); + async function flushMicrotasks(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 4f46dbc88c..e41186fdb1 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -38,6 +38,7 @@ import { import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; +import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; @@ -427,6 +428,8 @@ export class TerminalSessionController { } try { + const unavailable = this.terminalManager.getUnavailableError?.(); + if (unavailable) throw unavailable; let terminals: TerminalSession[]; if (msg.workspaceId !== undefined) { terminals = (await this.getAllTerminalSessions()).filter( @@ -449,6 +452,10 @@ export class TerminalSessionController { }); } catch (error) { this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals"); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } this.emit({ type: "list_terminals_response", payload: { @@ -460,6 +467,23 @@ export class TerminalSessionController { } } + // An empty list or a generic failure would read as "no terminals"; the client needs to know + // they exist and why it cannot reach them. + private emitIncompatible( + msg: { type: string; requestId: string }, + error: TerminalWorkerIncompatibleError, + ): void { + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: error.message, + code: error.code, + }, + }); + } + private async getAllTerminalSessions(): Promise { if (!this.terminalManager) { return []; @@ -597,6 +621,10 @@ export class TerminalSessionController { }); } catch (error) { this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal"); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } this.emit({ type: "create_terminal_response", payload: { diff --git a/packages/server/src/terminal/terminal-worker-client.test.ts b/packages/server/src/terminal/terminal-worker-client.test.ts new file mode 100644 index 0000000000..a363ab500d --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-client.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { isPlatform } from "../test-utils/platform.js"; +import { encodeFrame, FrameDecoder, TerminalWorkerFrameError } from "./terminal-worker-frames.js"; +import { connectTerminalWorker, TerminalWorkerConnectError } from "./terminal-worker-client.js"; +import { launchTerminalWorker } from "./terminal-worker-launch.js"; +import { terminalWorkerRecordPath } from "./terminal-worker-record.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("condition not met in time"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +describe("terminal worker frames", () => { + it("decodes frames split across chunks and several frames in one chunk", () => { + const first = encodeFrame({ type: "a", data: new Map([["k", 1]]) }); + const second = encodeFrame({ type: "b" }); + const joined = Buffer.concat([first, second]); + const decoder = new FrameDecoder(); + expect(decoder.push(joined.subarray(0, 3))).toEqual([]); + expect(decoder.push(joined.subarray(3, first.length + 2))).toEqual([ + { type: "a", data: new Map([["k", 1]]) }, + ]); + expect(decoder.push(joined.subarray(first.length + 2))).toEqual([{ type: "b" }]); + }); + + it("rejects a frame header announcing an oversized payload", () => { + const header = Buffer.alloc(4); + header.writeUInt32BE(0xffffffff, 0); + expect(() => new FrameDecoder().push(header)).toThrow(TerminalWorkerFrameError); + }); +}); + +describe("terminal worker authentication", () => { + it("closes a connection with the wrong secret and accepts the record's secret", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "tw-auth-")); + temporaryDirs.push(paseoHome); + const { record, recordPath } = await launchTerminalWorker({ + paseoHome, + paseoVersion: "0.0.0-test", + }); + expect(recordPath).toBe(terminalWorkerRecordPath(paseoHome, record.pid)); + if (!isPlatform("win32")) { + expect(statSync(recordPath).mode & 0o777).toBe(0o600); + expect(statSync(record.socketPath).mode & 0o777).toBe(0o600); + } + + await expect( + connectTerminalWorker({ socketPath: record.socketPath, secret: "wrong-secret" }), + ).rejects.toBeInstanceOf(TerminalWorkerConnectError); + + const connection = await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + }); + expect(connection.workerPid).toBe(record.pid); + // With no terminals, a worker whose controller leaves exits and removes its record. + connection.disconnect(); + await waitUntil(() => !existsSync(recordPath), 10_000); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-client.ts b/packages/server/src/terminal/terminal-worker-client.ts new file mode 100644 index 0000000000..532f3075c6 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-client.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from "node:events"; +import { createConnection } from "node:net"; +import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; +import { + TERMINAL_WORKER_PROTOCOL_VERSION, + type TerminalWorkerHello, + type TerminalWorkerHelloAck, + type TerminalWorkerRequest, + type TerminalWorkerToParentMessage, +} from "./terminal-worker-protocol.js"; + +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000; + +/** The daemon's (or cleanup's) authenticated connection to one terminal worker. */ +export interface TerminalWorkerConnection { + readonly connected: boolean; + readonly workerPid: number; + send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean; + /** Closes the socket. The worker keeps running and keeps its terminals. */ + disconnect(): void; + on(event: "message", listener: (message: TerminalWorkerToParentMessage) => void): this; + on(event: "close", listener: () => void): this; +} + +export class TerminalWorkerConnectError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerConnectError"; + } +} + +class SocketTerminalWorkerConnection extends EventEmitter implements TerminalWorkerConnection { + readonly workerPid: number; + private readonly framed: FramedSocket; + private closed = false; + + constructor(framed: FramedSocket, workerPid: number) { + super(); + this.framed = framed; + this.workerPid = workerPid; + framed.socket.once("close", () => { + this.closed = true; + this.emit("close"); + }); + } + + get connected(): boolean { + return !this.closed && !this.framed.socket.destroyed; + } + + send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean { + return this.framed.send(message, callback); + } + + disconnect(): void { + this.framed.close(); + } + + deliver(message: TerminalWorkerToParentMessage): void { + this.emit("message", message); + } +} + +function isHelloAck(message: unknown): message is TerminalWorkerHelloAck { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "helloAck" && + typeof (message as { pid?: unknown }).pid === "number" + ); +} + +/** + * Connects to a worker's socket and authenticates with the record's secret. The worker closes + * the socket on a wrong secret, so a rejected hello surfaces as a connect error. + */ +export function connectTerminalWorker(input: { + socketPath: string; + secret: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = input.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + return new Promise((resolve, reject) => { + const socket = createConnection(input.socketPath); + let connection: SocketTerminalWorkerConnection | null = null; + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + const timer = setTimeout(() => { + fail( + new TerminalWorkerConnectError( + `Terminal worker at ${input.socketPath} did not answer within ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + const framed = attachFramedSocket(socket, (message) => { + if (connection) { + connection.deliver(message as TerminalWorkerToParentMessage); + return; + } + if (!isHelloAck(message)) { + fail(new TerminalWorkerConnectError("Terminal worker sent an unexpected first frame")); + return; + } + settled = true; + clearTimeout(timer); + connection = new SocketTerminalWorkerConnection(framed, message.pid); + resolve(connection); + }); + // `on`, not `once`: an error after authentication must not go unhandled. `close` + // follows every socket error and ends the connection. + socket.on("error", (error) => { + fail( + new TerminalWorkerConnectError( + `Cannot connect to terminal worker at ${input.socketPath}: ${error.message}`, + { cause: error }, + ), + ); + }); + socket.once("close", () => { + fail( + new TerminalWorkerConnectError( + `Terminal worker at ${input.socketPath} closed the connection during authentication`, + ), + ); + }); + socket.once("connect", () => { + const hello: TerminalWorkerHello = { + type: "hello", + secret: input.secret, + protocolVersion: TERMINAL_WORKER_PROTOCOL_VERSION, + }; + framed.send(hello); + }); + }); +} diff --git a/packages/server/src/terminal/terminal-worker-frames.ts b/packages/server/src/terminal/terminal-worker-frames.ts new file mode 100644 index 0000000000..f0b990e239 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-frames.ts @@ -0,0 +1,98 @@ +import type { Socket } from "node:net"; +import { deserialize, serialize } from "node:v8"; + +// Frames on the terminal worker socket: a 4-byte big-endian length, then a v8-serialized +// payload (the same structured-clone semantics the old fork IPC used with +// `serialization: "advanced"`). The frame format is part of the frozen envelope: every worker +// version must keep it. +const FRAME_HEADER_BYTES = 4; +const MAX_FRAME_BYTES = 256 * 1024 * 1024; + +export class TerminalWorkerFrameError extends Error { + constructor(message: string) { + super(message); + this.name = "TerminalWorkerFrameError"; + } +} + +export function encodeFrame(value: unknown): Buffer { + const payload = serialize(value); + if (payload.length > MAX_FRAME_BYTES) { + throw new TerminalWorkerFrameError( + `Terminal worker frame of ${payload.length} bytes is too large`, + ); + } + const header = Buffer.allocUnsafe(FRAME_HEADER_BYTES); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +export class FrameDecoder { + private buffered: Buffer = Buffer.alloc(0); + + push(chunk: Buffer): unknown[] { + this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]); + const messages: unknown[] = []; + while (this.buffered.length >= FRAME_HEADER_BYTES) { + const length = this.buffered.readUInt32BE(0); + if (length > MAX_FRAME_BYTES) { + throw new TerminalWorkerFrameError(`Terminal worker frame of ${length} bytes is too large`); + } + const end = FRAME_HEADER_BYTES + length; + if (this.buffered.length < end) break; + messages.push(deserialize(this.buffered.subarray(FRAME_HEADER_BYTES, end))); + this.buffered = this.buffered.subarray(end); + } + return messages; + } +} + +export interface FramedSocket { + readonly socket: Socket; + send(message: unknown, callback?: (error: Error | null) => void): boolean; + close(): void; +} + +/** + * Wraps a connected socket in length-prefixed frames. A malformed frame destroys the socket: + * the peer is either not a terminal worker endpoint or is corrupt, and both end the session. + */ +export function attachFramedSocket( + socket: Socket, + onMessage: (message: unknown) => void, +): FramedSocket { + const decoder = new FrameDecoder(); + socket.on("data", (chunk: Buffer) => { + let messages: unknown[]; + try { + messages = decoder.push(chunk); + } catch (error) { + socket.destroy(error instanceof Error ? error : new Error(String(error))); + return; + } + for (const message of messages) { + if (socket.destroyed) return; + onMessage(message); + } + }); + return { + socket, + send(message, callback) { + if (socket.destroyed || !socket.writable) { + callback?.(new Error("Terminal worker socket is closed")); + return false; + } + let frame: Buffer; + try { + frame = encodeFrame(message); + } catch (error) { + callback?.(error instanceof Error ? error : new Error(String(error))); + return false; + } + return socket.write(frame, (error) => callback?.(error ?? null)); + }, + close() { + socket.end(() => socket.destroy()); + }, + }; +} diff --git a/packages/server/src/terminal/terminal-worker-launch.ts b/packages/server/src/terminal/terminal-worker-launch.ts new file mode 100644 index 0000000000..573abe3bd8 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-launch.ts @@ -0,0 +1,93 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { buildSelfNodeCommand } from "../server/paseo-env.js"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { + readTerminalWorkerRecord, + terminalWorkerDirectory, + terminalWorkerRecordPath, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { resolveTerminalWorkerEntry, type TerminalWorkerEntry } from "./terminal-worker-runtime.js"; + +const LAUNCH_TIMEOUT_MS = 20_000; + +export class TerminalWorkerLaunchError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerLaunchError"; + } +} + +/** + * Starts a terminal worker outside this process's tree: the launcher spawns the worker + * detached and exits once it listens, so the worker is re-parented and survives this daemon. + * Resolves with the record the worker wrote; a failed start leaves no worker behind. + */ +export async function launchTerminalWorker(input: { + paseoHome: string; + paseoVersion: string; + timeoutMs?: number; + /** Defaults to the copy of this installation's bundle (source in dev). */ + entry?: TerminalWorkerEntry; +}): Promise<{ record: TerminalWorkerRecord; recordPath: string }> { + const recordId = randomUUID(); + ensurePrivateDirectory(terminalWorkerDirectory(input.paseoHome)); + const entry = input.entry ?? resolveTerminalWorkerEntry(input); + const launch = buildSelfNodeCommand([ + ...entry.execArgv, + entry.scriptPath, + "--launch", + "--record-id", + recordId, + "--paseo-home", + input.paseoHome, + "--paseo-version", + input.paseoVersion, + ...(entry.runtime ? ["--runtime", entry.runtime] : []), + ]); + const stdout = await new Promise((resolve, reject) => { + execFile( + launch.command, + launch.args, + { + env: launch.env, + timeout: input.timeoutMs ?? LAUNCH_TIMEOUT_MS, + windowsHide: true, + encoding: "utf8", + }, + (error, out, err) => { + if (error) { + const detail = err.trim() || error.message; + reject( + new TerminalWorkerLaunchError(`Terminal worker failed to start: ${detail}`, { + cause: error, + }), + ); + return; + } + resolve(out); + }, + ); + }); + + let pid: number; + try { + const parsed = JSON.parse(stdout.trim()) as { pid?: unknown }; + if (typeof parsed.pid !== "number") throw new Error("missing pid"); + pid = parsed.pid; + } catch (error) { + throw new TerminalWorkerLaunchError( + `Terminal worker launcher printed an unexpected result: ${stdout.trim()}`, + { cause: error }, + ); + } + const recordPath = terminalWorkerRecordPath(input.paseoHome, pid); + const record = await readTerminalWorkerRecord(recordPath); + if (record.recordId !== recordId) { + throw new TerminalWorkerLaunchError( + `Terminal worker record ${recordPath} belongs to another launch`, + ); + } + return { record, recordPath }; +} diff --git a/packages/server/src/terminal/terminal-worker-ownership.test.ts b/packages/server/src/terminal/terminal-worker-ownership.test.ts new file mode 100644 index 0000000000..7abbb9aa62 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-ownership.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; +import { + classifyTerminalProgramProcess, + classifyTerminalWorkerProcess, +} from "./terminal-worker-ownership.js"; + +const BIRTH = "Wed Oct 21 18:12:49 2026"; +const OTHER_BIRTH = "Wed Oct 21 18:13:02 2026"; + +function alive(startedAt: string | null, commandLine: string | null): ManagedProcessInspection { + return { status: "alive", snapshot: { pid: 4242, startedAt, commandLine } }; +} + +const workerRecord = { + recordId: "record-1", + identity: { + startedAt: BIRTH, + commandLine: "node terminal-worker-process.js --paseo-terminal-worker record-1", + }, +}; + +describe("terminal program ownership", () => { + it("owns a program that ran exec: same pid and birth time, new command line", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(BIRTH, "sleep 100000")), + ).toEqual({ status: "owned" }); + }); + + it("treats a reused pid with the same command line but another birth time as unrelated", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(OTHER_BIRTH, "/bin/zsh -l")), + ).toMatchObject({ status: "unrelated" }); + }); + + it("never owns a process without a birth time on either side, whatever its command", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(null, "/bin/zsh -l")), + ).toMatchObject({ status: "unknown" }); + expect( + classifyTerminalProgramProcess({ startedAt: null }, alive(BIRTH, "/bin/zsh -l")), + ).toMatchObject({ status: "unknown" }); + }); + + it("reports an inspection error as unknown, not gone", () => { + expect( + classifyTerminalProgramProcess( + { startedAt: BIRTH }, + { status: "error", error: new Error("ps failed") }, + ), + ).toMatchObject({ status: "unknown", reason: expect.stringContaining("ps failed") }); + }); + + it("reports a missing process as gone", () => { + expect(classifyTerminalProgramProcess({ startedAt: BIRTH }, { status: "not-found" })).toEqual({ + status: "gone", + }); + }); +}); + +describe("terminal worker ownership", () => { + it("owns the worker whose birth time matches and whose command line carries its record id", () => { + expect( + classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, workerRecord.identity.commandLine)), + ).toEqual({ status: "owned" }); + }); + + it("treats another worker reusing the pid in the same second as unrelated", () => { + expect( + classifyTerminalWorkerProcess( + workerRecord, + alive(BIRTH, "node terminal-worker-process.js --paseo-terminal-worker record-2"), + ), + ).toMatchObject({ status: "unrelated" }); + }); + + it("treats a worker pid reused with another birth time as unrelated", () => { + expect( + classifyTerminalWorkerProcess( + workerRecord, + alive(OTHER_BIRTH, workerRecord.identity.commandLine), + ), + ).toMatchObject({ status: "unrelated" }); + }); + + it("does not own a worker whose command line cannot be read", () => { + expect(classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, null))).toMatchObject({ + status: "unknown", + }); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-ownership.ts b/packages/server/src/terminal/terminal-worker-ownership.ts new file mode 100644 index 0000000000..9bd02b01cd --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-ownership.ts @@ -0,0 +1,69 @@ +import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; +import { TERMINAL_WORKER_MARKER_ARG, type TerminalWorkerRecord } from "./terminal-worker-record.js"; + +// Who a pid named in a terminal worker record belongs to, before anything signals it. +// +// This is deliberately not `processIdentityMatches` from managed-processes.ts: that matcher +// falls back to command text when a birth time is missing, and requires the original argv when +// it is present. A terminal program is often a shell that ran `exec`, which keeps its pid and +// birth time but changes its command line; and a reused pid can run the same command line. +// Here birth time alone identifies a terminal program, and nothing without one is ever owned. + +export type ProcessOwnership = + | { status: "gone" } + | { status: "unrelated"; reason: string } + | { status: "owned" } + | { status: "unknown"; reason: string }; + +export function classifyTerminalWorkerProcess( + record: Pick, + inspection: ManagedProcessInspection, +): ProcessOwnership { + const birth = classifyBirth(record.identity.startedAt, inspection); + if (birth.status !== "owned" || inspection.status !== "alive") return birth; + const commandLine = inspection.snapshot.commandLine; + if (!commandLine) { + return { status: "unknown", reason: "command line unavailable" }; + } + if (!hasWorkerMarker(commandLine, record.recordId)) { + return { status: "unrelated", reason: "command line lacks the worker marker" }; + } + return { status: "owned" }; +} + +export function classifyTerminalProgramProcess( + entry: { startedAt: string | null }, + inspection: ManagedProcessInspection, +): ProcessOwnership { + return classifyBirth(entry.startedAt, inspection); +} + +function classifyBirth( + recordedStartedAt: string | null, + inspection: ManagedProcessInspection, +): ProcessOwnership { + if (inspection.status === "not-found") return { status: "gone" }; + if (inspection.status === "error") { + const message = + inspection.error instanceof Error ? inspection.error.message : String(inspection.error); + return { status: "unknown", reason: `inspection failed: ${message}` }; + } + const startedAt = inspection.snapshot.startedAt; + if (!recordedStartedAt || !startedAt) { + return { status: "unknown", reason: "birth time unavailable" }; + } + if (startedAt !== recordedStartedAt) { + return { status: "unrelated", reason: "pid reused by a process with another birth time" }; + } + return { status: "owned" }; +} + +function hasWorkerMarker(commandLine: string, recordId: string): boolean { + const tokens = commandLine.split(/\s+/); + for (let index = 0; index < tokens.length - 1; index += 1) { + if (tokens[index] === TERMINAL_WORKER_MARKER_ARG && tokens[index + 1] === recordId) { + return true; + } + } + return false; +} diff --git a/packages/server/src/terminal/terminal-worker-pool.ts b/packages/server/src/terminal/terminal-worker-pool.ts new file mode 100644 index 0000000000..f15ad5b46d --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-pool.ts @@ -0,0 +1,288 @@ +import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; +import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; +import type { + TerminalActivityListener, + TerminalsChangedListener, + TerminalWorkspaceContributionChangedListener, +} from "./terminal-manager.js"; +import type { TerminalSession, TerminalStateSnapshot } from "./terminal.js"; +import type { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js"; +import type { DaemonTerminalManager } from "./worker-terminal-manager.js"; + +// The daemon's terminal service over up to two terminal workers: the current one, which takes +// every new terminal, and one draining a previous worker protocol, which keeps serving the +// terminals it already runs and is let go once its last one exits. With no current worker (two +// were already running), the first terminal created after one exits starts one. A worker from a +// newer Paseo +// is not driven at all: the service then refuses terminal requests with +// TerminalWorkerIncompatibleError instead of serving a partial view. + +export class TerminalWorkerIncompatibleError extends Error { + readonly code = "terminal_worker_incompatible"; + readonly paseoVersion: string; + readonly protocolVersion: unknown; + + constructor(input: { paseoVersion: string; protocolVersion: unknown }) { + super( + `Terminals are held by a newer Paseo (v${input.paseoVersion}, worker protocol ${String(input.protocolVersion)}). Update Paseo to v${input.paseoVersion} or later to use them, or run \`paseo daemon stop\` to close them.`, + ); + this.name = "TerminalWorkerIncompatibleError"; + this.paseoVersion = input.paseoVersion; + this.protocolVersion = input.protocolVersion; + } +} + +export class NoTerminalWorkerAvailableError extends Error { + constructor(message: string) { + super(message); + this.name = "NoTerminalWorkerAvailableError"; + } +} + +export interface TerminalWorkerPoolMember { + manager: DaemonTerminalManager; + workerPid: number; + /** Resolves only after confirmed worker death and its scoped cleanup. */ + exited: Promise; + /** Waits for any reconnect; false once the member is retired or stopping. */ + ready(): Promise; + stopRecovery(): void; +} + +export interface TerminalWorkerPoolOptions { + current: TerminalWorkerPoolMember | null; + draining: TerminalWorkerPoolMember[]; + /** Set when a worker from a newer Paseo holds this home's terminals. */ + incompatible: TerminalWorkerIncompatibleError | null; + /** + * Starts a current worker when there is none. Throws NoTerminalWorkerAvailableError, saying + * why, while no worker may start. + */ + startCurrent?: () => Promise; + /** Closes every terminal of every worker in the home and verifies they ended. */ + closeAll: () => Promise; + /** A draining worker's last terminal exited and the pool let it go. */ + onDrained?: (member: TerminalWorkerPoolMember) => void; +} + +export function createTerminalWorkerPool( + options: TerminalWorkerPoolOptions, +): DaemonTerminalManager { + let current = options.current; + const draining = new Set(options.draining); + const terminalsChangedListeners = new Set(); + const terminalActivityListeners = new Set(); + const contributionListeners = new Set(); + let closePromise: Promise | null = null; + let detached = false; + // One start at a time, so concurrent creates never launch two workers. + let starting: Promise | null = null; + // Registrations belong to the service and are replayed whenever a current worker starts. + const cwdEnv = new Map>(); + + function members(): TerminalWorkerPoolMember[] { + return [...(current ? [current] : []), ...draining]; + } + + function assertAvailable(): void { + if (options.incompatible) throw options.incompatible; + } + + function ownerOf(terminalId: string): DaemonTerminalManager | null { + for (const member of members()) { + if (member.manager.getTerminal(terminalId)) return member.manager; + } + return null; + } + + function routed(terminalId: string): DaemonTerminalManager { + assertAvailable(); + const owner = ownerOf(terminalId) ?? current?.manager; + if (!owner) { + throw new NoTerminalWorkerAvailableError(`Terminal ${terminalId} is not running`); + } + return owner; + } + + function releaseIfDrained(member: TerminalWorkerPoolMember): void { + if (!draining.has(member) || member.manager.listDirectories().length > 0) return; + draining.delete(member); + // With no controller and no terminals the worker exits on its own. The request that closed + // the last terminal is still waiting for its answer. + void member.manager + .detach({ afterPendingRequests: true }) + .then(() => options.onDrained?.(member)); + } + + function forwardEvents(member: TerminalWorkerPoolMember): void { + void member.exited.then(() => { + if (current === member) current = null; + return draining.delete(member); + }); + member.manager.subscribeTerminalsChanged((event) => { + for (const listener of Array.from(terminalsChangedListeners)) listener(event); + releaseIfDrained(member); + }); + member.manager.subscribeTerminalActivity((event) => { + for (const listener of Array.from(terminalActivityListeners)) listener(event); + }); + member.manager.subscribeTerminalWorkspaceContributionChanged((event) => { + for (const listener of Array.from(contributionListeners)) listener(event); + }); + } + + for (const member of members()) forwardEvents(member); + for (const member of Array.from(draining)) releaseIfDrained(member); + + async function currentManager(): Promise { + if (closePromise || detached) + throw new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"); + const candidate = current; + if (candidate && (await candidate.ready())) return candidate.manager; + const startCurrent = options.startCurrent; + if (!startCurrent || closePromise || detached) { + return Promise.reject( + new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"), + ); + } + starting ??= startCurrent() + .then(async (member) => { + if (closePromise || detached) { + // The service closed or let go while the worker started: it takes no terminals. + member.stopRecovery(); + await member.manager.detach(); + throw new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"); + } + forwardEvents(member); + for (const [cwd, env] of cwdEnv) member.manager.registerCwdEnv({ cwd, env }); + current = member; + return member; + }) + .finally(() => { + starting = null; + }); + return starting.then((member) => member.manager); + } + + function closeTerminals(): Promise { + closePromise ??= (async () => { + for (const member of members()) member.stopRecovery(); + await starting?.catch(() => {}); + await Promise.all(members().map((member) => member.manager.detach())); + draining.clear(); + await options.closeAll(); + })(); + return closePromise; + } + + return { + getUnavailableError(): Error | null { + return options.incompatible; + }, + + async getTerminals(cwd, filter) { + assertAvailable(); + const lists = await Promise.all( + members().map((member) => member.manager.getTerminals(cwd, filter)), + ); + return lists.flat(); + }, + + async createTerminal( + createOptions: WorkerCreateTerminalOptions & { workspaceId: string }, + ): Promise { + assertAvailable(); + const manager = await currentManager(); + return manager.createTerminal(createOptions); + }, + + registerCwdEnv(registration) { + cwdEnv.set(registration.cwd, registration.env); + if (current) current.manager.registerCwdEnv(registration); + }, + + validateTerminalActivityToken(terminalId, token) { + for (const member of members()) { + const result = member.manager.validateTerminalActivityToken(terminalId, token); + if (result !== "unknown") return result; + } + return "unknown"; + }, + + getTerminal(id) { + for (const member of members()) { + const session = member.manager.getTerminal(id); + if (session) return session; + } + return undefined; + }, + + async getTerminalState(id, stateOptions): Promise { + return routed(id).getTerminalState(id, stateOptions); + }, + + setTerminalTitle(id, title) { + return ownerOf(id)?.setTerminalTitle(id, title) ?? false; + }, + + async setTerminalActivity(id, state: TerminalActivityState) { + return (await ownerOf(id)?.setTerminalActivity(id, state)) ?? false; + }, + + async clearTerminalAttention(id) { + return (await ownerOf(id)?.clearTerminalAttention(id)) ?? false; + }, + + killTerminal(id) { + ownerOf(id)?.killTerminal(id); + }, + + async killTerminalAndWait(id, waitOptions) { + await routed(id).killTerminalAndWait(id, waitOptions); + }, + + async captureTerminal(id, captureOptions): Promise { + return routed(id).captureTerminal(id, captureOptions); + }, + + listDirectories() { + return [...new Set(members().flatMap((member) => member.manager.listDirectories()))]; + }, + + killAll() { + void closeTerminals().catch(() => { + // The synchronous interface cannot report it; closeTerminals() callers see the error. + }); + }, + + closeTerminals, + + async detach() { + detached = true; + for (const member of members()) member.stopRecovery(); + await starting?.catch(() => {}); + await Promise.all(members().map((member) => member.manager.detach())); + }, + + async adoptTerminals() { + const counts = await Promise.all(members().map((member) => member.manager.adoptTerminals())); + for (const member of Array.from(draining)) releaseIfDrained(member); + return counts.reduce((sum, count) => sum + count, 0); + }, + + subscribeTerminalsChanged(listener) { + terminalsChangedListeners.add(listener); + return () => terminalsChangedListeners.delete(listener); + }, + + subscribeTerminalActivity(listener) { + terminalActivityListeners.add(listener); + return () => terminalActivityListeners.delete(listener); + }, + + subscribeTerminalWorkspaceContributionChanged(listener) { + contributionListeners.add(listener); + return () => contributionListeners.delete(listener); + }, + }; +} diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index a70d37c5b9..6c13ba04d5 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -1,352 +1,777 @@ +import { spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { fileURLToPath } from "node:url"; +import { currentBootId } from "../server/pid-lock.js"; +import { PRIVATE_FILE_MODE } from "../server/private-files.js"; import { createTerminalManager } from "./terminal-manager.js"; import { captureTerminalLines } from "./terminal-capture.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; -import type { TerminalSession, TerminalStateSnapshotOptions } from "./terminal.js"; import type { - TerminalWorkerRequest, - TerminalWorkerStateResult, - TerminalWorkerToParentMessage, - WorkerTerminalInfo, + TerminalExitInfo, + TerminalSession, + TerminalStateSnapshotOptions, +} from "./terminal.js"; +import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; +import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; +import { + TERMINAL_WORKER_MARKER_ARG, + terminalWorkerLogPath, + terminalWorkerRecordPath, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { resolveTerminalWorkerSocketPath } from "./terminal-worker-socket-path.js"; +import { + TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV, + TERMINAL_WORKER_PROTOCOL_VERSION, + type TerminalWorkerHello, + type TerminalWorkerHelloAck, + type TerminalWorkerListResult, + type TerminalWorkerRequest, + type TerminalWorkerStateResult, + type TerminalWorkerToParentMessage, + type WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; +// The terminal worker owns every PTY. It runs detached from the daemon (re-parented by the +// launcher below) so a daemon restart, crash or forced kill of the daemon's process tree does +// not take the terminals down, and it talks to whichever daemon controls it over an +// owner-only socket authenticated by the secret in its record. +// +// `--launch` runs the launcher: it starts the real worker detached, waits for it to listen, +// prints `{ pid, socketPath }` and exits, which leaves the worker parented to init. + +const LAUNCH_ARG = "--launch"; +const READY_TIMEOUT_MS = 15_000; +const HELLO_TIMEOUT_MS = 5_000; +// A freshly launched worker waits this long for its first controller before giving up. +const FIRST_CONTROLLER_TIMEOUT_MS = 30_000; +// While nobody controls it, the worker checks that its record still exists. A deleted record +// (a removed $PASEO_HOME) means no daemon can ever find these terminals again. +const ORPHAN_CHECK_INTERVAL_MS = 10_000; +const READY_FD = 3; + +interface WorkerArgs { + recordId: string; + paseoHome: string; + paseoVersion: string; + // The copy under terminal-worker/ this worker runs from; absent when it runs from source. + runtime: string | null; +} + +function readArg(argv: string[], name: string): string { + const index = argv.indexOf(name); + const value = index === -1 ? undefined : argv[index + 1]; + if (!value) { + throw new Error(`Terminal worker requires ${name}`); + } + return value; +} + +function parseWorkerArgs(argv: string[], recordIdArg: string): WorkerArgs { + return { + recordId: readArg(argv, recordIdArg), + paseoHome: readArg(argv, "--paseo-home"), + paseoVersion: readArg(argv, "--paseo-version"), + runtime: argv.includes("--runtime") ? readArg(argv, "--runtime") : null, + }; +} + +// The protocol version this worker reports in its record and hello. Tests override it to stand +// up older and newer workers from one build. +function reportedProtocolVersion(): number { + const override = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + if (override === undefined || override === "") return TERMINAL_WORKER_PROTOCOL_VERSION; + const parsed = Number(override); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`${TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV} is not an integer: ${override}`); + } + return parsed; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------------------------- +// Launcher +// --------------------------------------------------------------------------------------------- + +function runLauncher(argv: string[]): void { + const args = parseWorkerArgs(argv, "--record-id"); + const logFd = openSync(terminalWorkerLogPath(args.paseoHome), "a", PRIVATE_FILE_MODE); + const child = spawn( + process.execPath, + [ + ...process.execArgv, + fileURLToPath(import.meta.url), + TERMINAL_WORKER_MARKER_ARG, + args.recordId, + "--paseo-home", + args.paseoHome, + "--paseo-version", + args.paseoVersion, + ...(args.runtime ? ["--runtime", args.runtime] : []), + ], + { + detached: true, + stdio: ["ignore", "ignore", logFd, "pipe"], + env: process.env, + windowsHide: true, + }, + ); + closeSync(logFd); + + let settled = false; + let buffered = ""; + const finish = (code: number, output: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0 && child.exitCode === null && child.pid) { + // A worker that never reported ready must not be left behind. + try { + process.kill(child.pid, "SIGKILL"); + } catch { + // Already gone. + } + } + child.unref(); + (child.stdio[READY_FD] as NodeJS.ReadableStream & { destroy(): void }).destroy(); + // Exit from the write callback: a pipe write can still be pending when process.exit runs. + (code === 0 ? process.stdout : process.stderr).write(output, () => process.exit(code)); + }; + const timer = setTimeout(() => { + finish(1, `Terminal worker did not report ready within ${READY_TIMEOUT_MS}ms\n`); + }, READY_TIMEOUT_MS); + + const readyStream = child.stdio[READY_FD] as NodeJS.ReadableStream; + readyStream.setEncoding("utf8"); + readyStream.on("data", (chunk: string) => { + buffered += chunk; + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + if (line.startsWith("ready ")) { + finish(0, `${JSON.stringify({ pid: child.pid, socketPath: line.slice("ready ".length) })}\n`); + return; + } + finish(1, `${line.startsWith("error ") ? line.slice("error ".length) : line}\n`); + }); + child.once("error", (error) => finish(1, `Cannot start terminal worker: ${error.message}\n`)); + child.once("exit", (code, signal) => { + finish(1, `Terminal worker exited before it was ready (${signal ?? `code ${code}`})\n`); + }); +} + +// --------------------------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------------------------- + type TerminalCreateRequest = Extract; -const manager = createTerminalManager(); -const unsubscribeByTerminalId = new Map void>>(); -const outputCoalescerByTerminalId = new Map(); -let ipcClosing = false; +function runWorker(argv: string[]): void { + const args = parseWorkerArgs(argv, TERMINAL_WORKER_MARKER_ARG); + const protocolVersion = reportedProtocolVersion(); + const manager = createTerminalManager(); + const inspector = createTerminalWorkerProcessInspector(); + const sessionsById = new Map(); + const unsubscribeByTerminalId = new Map void>>(); + const outputCoalescerByTerminalId = new Map(); + // A terminal is published (terminalCreated) only once its program is recorded. An exit that + // arrives before then is held here and sent after the creation, so the controller never + // receives an exit for a terminal it does not know yet. + const exitsHeldDuringCreation = new Map(); + const secret = randomBytes(32).toString("base64url"); + let record: TerminalWorkerRecord | null = null; + let server: Server | null = null; + let controller: FramedSocket | null = null; + let everControlled = false; + let exiting = false; -interface InFlightTerminalCreateRequest { - requestId: string; - errorReported: boolean; -} - -let inFlightTerminalCreateRequest: InFlightTerminalCreateRequest | null = null; - -// The conpty failure signal is process-scoped, not request-scoped. Serializing -// creates keeps an async spawn failure attributable to exactly one request. -let createTerminalQueue: Promise = Promise.resolve(); - -// node-pty completes its Windows conpty spawn asynchronously on a separate -// conout worker thread. When that spawn fails (bad cwd, missing command, etc.) -// it throws an exception there that cannot be caught at the call site and would -// otherwise crash this worker process and sever every existing terminal. -process.on("uncaughtException", (error) => { - console.error("Terminal worker uncaught exception (kept alive):", error); - reportInFlightTerminalCreateFailure(error); -}); - -function sendToParent(message: TerminalWorkerToParentMessage): void { - if (ipcClosing || !process.connected || !process.send) { - return; + interface InFlightTerminalCreateRequest { + requestId: string; + errorReported: boolean; } - try { - process.send(message, (error) => { - if (error) { - ipcClosing = true; - } - }); - } catch { - ipcClosing = true; - } -} -function buildTerminalStateResult( - session: TerminalSession | undefined, - options?: TerminalStateSnapshotOptions, -): TerminalWorkerStateResult { - if (!session) { - return null; - } - return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() }; -} + let inFlightTerminalCreateRequest: InFlightTerminalCreateRequest | null = null; -function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { - return { - id: session.id, - name: session.name, - cwd: session.cwd, - workspaceId: session.workspaceId, - ...(session.getTitle() ? { title: session.getTitle() } : {}), - activity: session.getActivity(), - }; -} + // The conpty failure signal is process-scoped, not request-scoped. Serializing + // creates keeps an async spawn failure attributable to exactly one request. + let createTerminalQueue: Promise = Promise.resolve(); -function terminalWorkerErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Terminal worker request failed"; -} - -function reportInFlightTerminalCreateFailure(error: unknown): void { - if (!inFlightTerminalCreateRequest || inFlightTerminalCreateRequest.errorReported) { - return; - } - inFlightTerminalCreateRequest.errorReported = true; - sendToParent({ - type: "response", - requestId: inFlightTerminalCreateRequest.requestId, - ok: false, - error: terminalWorkerErrorMessage(error), - }); -} - -function clearTerminalSubscriptions(terminalId: string): void { - const subscriptions = unsubscribeByTerminalId.get(terminalId); - if (subscriptions) { - for (const unsubscribe of subscriptions) { - try { - unsubscribe(); - } catch { - // no-op - } - } - } - unsubscribeByTerminalId.delete(terminalId); - const coalescer = outputCoalescerByTerminalId.get(terminalId); - if (coalescer) { - coalescer.dispose(); - outputCoalescerByTerminalId.delete(terminalId); - } -} - -function watchTerminal(session: TerminalSession): void { - clearTerminalSubscriptions(session.id); - - // Coalesce pty output chunks into a single IPC message per ~5ms window so a - // burst of small chunks no longer costs one process.send each. The batch - // carries the LAST chunk's revision (the highest) so downstream snapshot - // replay dedup stays correct. - let pendingOutputRevision: number | undefined; - const outputCoalescer = new TerminalOutputCoalescer({ - timers: { setTimeout, clearTimeout }, - onFlush: ({ payload }) => { - const revision = pendingOutputRevision; - pendingOutputRevision = undefined; - sendToParent({ - type: "terminalMessage", - terminalId: session.id, - message: { type: "output", data: payload.toString("utf8"), revision }, - replayPreamble: session.getReplayPreamble(), - }); - }, - }); - outputCoalescerByTerminalId.set(session.id, outputCoalescer); - - const unsubscribeMessage = session.subscribe( - (message) => { - if (message.type === "output") { - pendingOutputRevision = message.revision; - outputCoalescer.handle(message.data); - return; - } - // Non-output messages (snapshot/snapshotReady/titleChange) must not jump - // ahead of buffered output: flush the coalescer first, then forward. - outputCoalescer.flush(); - sendToParent({ - type: "terminalMessage", - terminalId: session.id, - message, - replayPreamble: session.getReplayPreamble(), - }); - }, - // Creation already sends an authoritative state in terminalCreated. A second - // asynchronous state snapshot can arrive after the create response and - // overwrite a resize issued immediately by the caller. - { initialSnapshot: "ready" }, - ); - const unsubscribeExit = session.onExit((info) => { - outputCoalescer.flush(); - clearTerminalSubscriptions(session.id); - sendToParent({ - type: "terminalExit", - terminalId: session.id, - info, - }); - }); - const unsubscribeTitle = session.onTitleChange((title) => { - outputCoalescer.flush(); - sendToParent({ - type: "terminalTitleChange", - terminalId: session.id, - title, - }); - }); - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - outputCoalescer.flush(); - sendToParent({ - type: "terminalCommandFinished", - terminalId: session.id, - info, - }); - }); - const unsubscribeActivity = session.onActivityChange((transition) => { - sendToParent({ - type: "terminalActivityChange", - terminalId: session.id, - activity: transition.activity, - previous: transition.previous, - }); - }); - - unsubscribeByTerminalId.set(session.id, [ - unsubscribeMessage, - unsubscribeExit, - unsubscribeTitle, - unsubscribeCommandFinished, - unsubscribeActivity, - ]); -} - -function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { - const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message)); - createTerminalQueue = nextRequest.catch(() => {}); - return nextRequest; -} - -async function handleCreateTerminalRequest(message: TerminalCreateRequest): Promise { - const request: InFlightTerminalCreateRequest = { - requestId: message.requestId, - errorReported: false, - }; - inFlightTerminalCreateRequest = request; - try { - const { workspaceId } = message.options; - if (!workspaceId) { - throw new Error("workspaceId is required"); - } - const session = await manager.createTerminal({ ...message.options, workspaceId }); - if (request.errorReported) { - session.kill(); - return; - } - watchTerminal(session); - const initialSnapshot = session.getStateSnapshot(); - sendToParent({ - type: "terminalCreated", - terminal: toTerminalInfo(session), - state: initialSnapshot.state, - }); - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result: { - terminal: toTerminalInfo(session), - state: initialSnapshot.state, - }, - }); - } catch (error) { + // node-pty completes its Windows conpty spawn asynchronously on a separate + // conout worker thread. When that spawn fails (bad cwd, missing command, etc.) + // it throws an exception there that cannot be caught at the call site and would + // otherwise crash this worker process and sever every existing terminal. + process.on("uncaughtException", (error) => { + console.error("Terminal worker uncaught exception (kept alive):", error); reportInFlightTerminalCreateFailure(error); - } finally { - if (inFlightTerminalCreateRequest === request) { - inFlightTerminalCreateRequest = null; + }); + + function sendToController(message: TerminalWorkerToParentMessage): void { + // With no controller the event is dropped; the headless terminal keeps the screen, and the + // next daemon reads it through listTerminals. + controller?.send(message); + } + + function persistRecord(): void { + if (!record || exiting) return; + record = { + ...record, + terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)), + }; + writeTerminalWorkerRecord(args.paseoHome, record); + } + + function removeOwnFiles(): void { + rmSync(terminalWorkerRecordPath(args.paseoHome, process.pid), { force: true }); + if (record && process.platform !== "win32") { + rmSync(record.socketPath, { force: true }); } } -} -async function handleRequest(message: TerminalWorkerRequest): Promise { - switch (message.type) { - case "createTerminal": { - await enqueueCreateTerminalRequest(message); - return; - } + function exitWorker(code: number): void { + if (exiting) return; + exiting = true; + removeOwnFiles(); + server?.close(); + process.exit(code); + } - case "registerCwdEnv": { - manager.registerCwdEnv({ cwd: message.cwd, env: message.env }); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } + async function killEveryTerminalAndExit(): Promise { + const sessions = Array.from(sessionsById.values()); + await Promise.all( + sessions.map((session) => + session.killAndWait().catch((error: unknown) => { + console.error(`Terminal worker could not kill terminal ${session.id}:`, error); + }), + ), + ); + exitWorker(0); + } - case "setActivity": { - await manager.setTerminalActivity(message.terminalId, message.state); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "clearAttention": { - await manager.clearTerminalAttention(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "killTerminal": { - manager.killTerminal(message.terminalId); - // Removal is owned by session.onExit -> terminalExit; the parent mirror - // clears contribution and emits terminalsChanged from that single path. - clearTerminalSubscriptions(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "killTerminalAndWait": { - await manager.killTerminalAndWait(message.terminalId, message.options); - clearTerminalSubscriptions(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "getTerminalState": { - // Flush buffered output before snapshotting: the headless state already includes it, - // so if the coalescer emitted it afterward (in a batch carrying a revision past the - // snapshot's) the controller's revision dedup wouldn't drop it and the client would - // see the bytes twice. Flushing first sends them with a revision <= the snapshot's. - outputCoalescerByTerminalId.get(message.terminalId)?.flush(); - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result: buildTerminalStateResult(manager.getTerminal(message.terminalId), message.options), - }); - return; - } - - case "captureTerminal": { - const session = manager.getTerminal(message.terminalId); - const result = session - ? captureTerminalLines(session, { - start: message.start, - end: message.end, - stripAnsi: message.stripAnsi, - }) - : { lines: [], totalLines: 0 }; - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result, - }); - return; - } - - case "killAll": { - manager.killAll(); - for (const terminalId of Array.from(unsubscribeByTerminalId.keys())) { - clearTerminalSubscriptions(terminalId); - } - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "send": { - const session = manager.getTerminal(message.terminalId); - session?.send(message.message); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; + function exitIfIdle(): void { + if (!controller && everControlled && sessionsById.size === 0) { + exitWorker(0); } } -} -process.on("message", (message: TerminalWorkerRequest) => { - void handleRequest(message).catch((error: unknown) => { - sendToParent({ + function buildTerminalStateResult( + session: TerminalSession | undefined, + options?: TerminalStateSnapshotOptions, + ): TerminalWorkerStateResult { + if (!session) { + return null; + } + return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() }; + } + + function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { + return { + id: session.id, + name: session.name, + cwd: session.cwd, + workspaceId: session.workspaceId, + ...(session.getTitle() ? { title: session.getTitle() } : {}), + activity: session.getActivity(), + }; + } + + function terminalWorkerErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Terminal worker request failed"; + } + + function reportInFlightTerminalCreateFailure(error: unknown): void { + if (!inFlightTerminalCreateRequest || inFlightTerminalCreateRequest.errorReported) { + return; + } + inFlightTerminalCreateRequest.errorReported = true; + sendToController({ type: "response", - requestId: message.requestId, + requestId: inFlightTerminalCreateRequest.requestId, ok: false, error: terminalWorkerErrorMessage(error), }); - }); -}); + } -process.once("disconnect", () => { - ipcClosing = true; - manager.killAll(); -}); + function clearTerminalSubscriptions(terminalId: string): void { + const subscriptions = unsubscribeByTerminalId.get(terminalId); + if (subscriptions) { + for (const unsubscribe of subscriptions) { + try { + unsubscribe(); + } catch { + // no-op + } + } + } + unsubscribeByTerminalId.delete(terminalId); + const coalescer = outputCoalescerByTerminalId.get(terminalId); + if (coalescer) { + coalescer.dispose(); + outputCoalescerByTerminalId.delete(terminalId); + } + } + + function watchTerminal(session: TerminalSession): void { + clearTerminalSubscriptions(session.id); + + // Coalesce pty output chunks into a single frame per ~5ms window so a burst of small + // chunks no longer costs one socket write each. The batch carries the LAST chunk's + // revision (the highest) so downstream snapshot replay dedup stays correct. + let pendingOutputRevision: number | undefined; + const outputCoalescer = new TerminalOutputCoalescer({ + timers: { setTimeout, clearTimeout }, + onFlush: ({ payload }) => { + const revision = pendingOutputRevision; + pendingOutputRevision = undefined; + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message: { type: "output", data: payload.toString("utf8"), revision }, + replayPreamble: session.getReplayPreamble(), + }); + }, + }); + outputCoalescerByTerminalId.set(session.id, outputCoalescer); + + const unsubscribeMessage = session.subscribe( + (message) => { + if (message.type === "output") { + pendingOutputRevision = message.revision; + outputCoalescer.handle(message.data); + return; + } + // Non-output messages (snapshot/snapshotReady/titleChange) must not jump + // ahead of buffered output: flush the coalescer first, then forward. + outputCoalescer.flush(); + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message, + replayPreamble: session.getReplayPreamble(), + }); + }, + // Creation already sends an authoritative state in terminalCreated. A second + // asynchronous state snapshot can arrive after the create response and + // overwrite a resize issued immediately by the caller. + { initialSnapshot: "ready" }, + ); + const unsubscribeExit = session.onExit((info) => { + outputCoalescer.flush(); + clearTerminalSubscriptions(session.id); + sessionsById.delete(session.id); + persistRecord(); + if (exitsHeldDuringCreation.has(session.id)) { + exitsHeldDuringCreation.set(session.id, info); + return; + } + sendToController({ + type: "terminalExit", + terminalId: session.id, + info, + }); + exitIfIdle(); + }); + const unsubscribeTitle = session.onTitleChange((title) => { + outputCoalescer.flush(); + sendToController({ + type: "terminalTitleChange", + terminalId: session.id, + title, + }); + }); + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + outputCoalescer.flush(); + sendToController({ + type: "terminalCommandFinished", + terminalId: session.id, + info, + }); + }); + const unsubscribeActivity = session.onActivityChange((transition) => { + sendToController({ + type: "terminalActivityChange", + terminalId: session.id, + activity: transition.activity, + previous: transition.previous, + }); + }); + + unsubscribeByTerminalId.set(session.id, [ + unsubscribeMessage, + unsubscribeExit, + unsubscribeTitle, + unsubscribeCommandFinished, + unsubscribeActivity, + ]); + } + + // A terminal enters the record only with its program's birth time, so cleanup can always + // tell the program from a later process that reuses its pid. A program that already exited + // needs no entry; one whose birth time cannot be read is killed and the create fails. + async function recordTerminalProgram(session: TerminalSession): Promise { + if (session.getExitInfo()) return; + // The manager drops a session as soon as its program exits, so only a live one has a pid. + const pid = manager.getTerminalProcessId(session.id); + if (pid === null) { + throw new Error(`Terminal ${session.id} has no program pid`); + } + const inspection = await inspector.inspect(pid); + if (inspection.status === "not-found" || session.getExitInfo()) { + return; + } + if (inspection.status === "error" || !inspection.snapshot.startedAt) { + const reason = + inspection.status === "error" ? errorMessage(inspection.error) : "no birth time reported"; + throw new Error(`Cannot identify terminal program ${pid}: ${reason}`); + } + if (!record) { + throw new Error("Terminal worker record is not written yet"); + } + record = { + ...record, + terminals: [ + ...record.terminals.filter((entry) => entry.id !== session.id), + { id: session.id, pid, startedAt: inspection.snapshot.startedAt }, + ], + }; + persistRecord(); + } + + function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { + const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message)); + createTerminalQueue = nextRequest.catch(() => {}); + return nextRequest; + } + + async function handleCreateTerminalRequest(message: TerminalCreateRequest): Promise { + const request: InFlightTerminalCreateRequest = { + requestId: message.requestId, + errorReported: false, + }; + inFlightTerminalCreateRequest = request; + try { + const { workspaceId } = message.options; + if (!workspaceId) { + throw new Error("workspaceId is required"); + } + const session = await manager.createTerminal({ ...message.options, workspaceId }); + if (request.errorReported) { + session.kill(); + return; + } + sessionsById.set(session.id, session); + exitsHeldDuringCreation.set(session.id, null); + watchTerminal(session); + try { + await recordTerminalProgram(session); + } catch (error) { + exitsHeldDuringCreation.delete(session.id); + clearTerminalSubscriptions(session.id); + sessionsById.delete(session.id); + await session.killAndWait().catch(() => undefined); + throw error; + } + const initialSnapshot = session.getStateSnapshot(); + sendToController({ + type: "terminalCreated", + terminal: toTerminalInfo(session), + state: initialSnapshot.state, + }); + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result: { + terminal: toTerminalInfo(session), + state: initialSnapshot.state, + }, + }); + const heldExit = exitsHeldDuringCreation.get(session.id); + exitsHeldDuringCreation.delete(session.id); + if (heldExit) { + sendToController({ type: "terminalExit", terminalId: session.id, info: heldExit }); + exitIfIdle(); + } + } catch (error) { + reportInFlightTerminalCreateFailure(error); + } finally { + if (inFlightTerminalCreateRequest === request) { + inFlightTerminalCreateRequest = null; + } + } + } + + function listTerminals(): TerminalWorkerListResult { + return Array.from(sessionsById.values()).map((session) => { + // Buffered output is already in the headless state; flushing first keeps a later + // batch from carrying bytes the snapshot already shows. + outputCoalescerByTerminalId.get(session.id)?.flush(); + return { + terminal: toTerminalInfo(session), + state: session.getStateSnapshot().state, + activityToken: manager.getTerminalActivityToken(session.id), + replayPreamble: session.getReplayPreamble(), + }; + }); + } + + async function handleRequest(message: TerminalWorkerRequest): Promise { + switch (message.type) { + case "createTerminal": { + await enqueueCreateTerminalRequest(message); + return; + } + + case "registerCwdEnv": { + manager.registerCwdEnv({ cwd: message.cwd, env: message.env }); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "setActivity": { + await manager.setTerminalActivity(message.terminalId, message.state); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "clearAttention": { + await manager.clearTerminalAttention(message.terminalId); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "killTerminal": { + manager.killTerminal(message.terminalId); + // Removal is owned by session.onExit -> terminalExit; the parent mirror + // clears contribution and emits terminalsChanged from that single path. + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "killTerminalAndWait": { + await manager.killTerminalAndWait(message.terminalId, message.options); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "getTerminalState": { + // Flush buffered output before snapshotting: the headless state already includes it, + // so if the coalescer emitted it afterward (in a batch carrying a revision past the + // snapshot's) the controller's revision dedup wouldn't drop it and the client would + // see the bytes twice. Flushing first sends them with a revision <= the snapshot's. + outputCoalescerByTerminalId.get(message.terminalId)?.flush(); + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result: buildTerminalStateResult( + manager.getTerminal(message.terminalId), + message.options, + ), + }); + return; + } + + case "captureTerminal": { + const session = manager.getTerminal(message.terminalId); + const result = session + ? captureTerminalLines(session, { + start: message.start, + end: message.end, + stripAnsi: message.stripAnsi, + }) + : { lines: [], totalLines: 0 }; + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result, + }); + return; + } + + case "listTerminals": { + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result: listTerminals(), + }); + return; + } + + case "shutdown": { + const sessions = Array.from(sessionsById.values()); + await Promise.all(sessions.map((session) => session.killAndWait().catch(() => undefined))); + // Exit once the response frame is flushed, so the requester sees the confirmation. + const requester = controller; + if (!requester) { + exitWorker(0); + return; + } + requester.send({ type: "response", requestId: message.requestId, ok: true }, () => + exitWorker(0), + ); + return; + } + + case "send": { + const session = manager.getTerminal(message.terminalId); + session?.send(message.message); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + } + } + + function isHello(message: unknown): message is TerminalWorkerHello { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "hello" && + typeof (message as { secret?: unknown }).secret === "string" + ); + } + + function secretMatches(candidate: string): boolean { + const expected = Buffer.from(secret, "utf8"); + const actual = Buffer.from(candidate, "utf8"); + return expected.length === actual.length && timingSafeEqual(expected, actual); + } + + function handleConnection(socket: Socket): void { + let authenticated = false; + socket.on("error", () => { + // `close` follows; a broken controller socket only means no controller. + }); + const helloTimer = setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS); + const framed = attachFramedSocket(socket, (message) => { + if (!authenticated) { + clearTimeout(helloTimer); + if (!isHello(message) || !secretMatches(message.secret)) { + socket.destroy(); + return; + } + authenticated = true; + // One controller at a time: a new authenticated connection replaces the old one. + const previous = controller; + controller = framed; + everControlled = true; + previous?.socket.destroy(); + const ack: TerminalWorkerHelloAck = { + type: "helloAck", + pid: process.pid, + protocolVersion, + }; + framed.send(ack); + return; + } + if (framed !== controller) { + return; + } + const request = message as TerminalWorkerRequest; + void handleRequest(request).catch((error: unknown) => { + sendToController({ + type: "response", + requestId: request.requestId, + ok: false, + error: terminalWorkerErrorMessage(error), + }); + }); + }); + socket.once("close", () => { + clearTimeout(helloTimer); + if (controller === framed) { + controller = null; + exitIfIdle(); + } + }); + } + + function reportReady(line: string): void { + try { + writeSync(READY_FD, `${line}\n`); + closeSync(READY_FD); + } catch { + // Started without a launcher pipe (tests that spawn the worker directly). + } + } + + async function start(): Promise { + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: args.paseoHome, + workerPid: process.pid, + }); + if (process.platform !== "win32") { + rmSync(socketPath, { force: true }); + } + const listening = createServer(handleConnection); + await new Promise((resolve, reject) => { + listening.once("error", reject); + listening.listen(socketPath, () => { + listening.off("error", reject); + resolve(); + }); + }); + server = listening; + if (process.platform !== "win32") { + chmodSync(socketPath, PRIVATE_FILE_MODE); + } + + const self = await inspector.inspect(process.pid); + if ( + self.status !== "alive" || + !self.snapshot.startedAt || + !self.snapshot.commandLine?.includes(args.recordId) + ) { + const reason = self.status === "error" ? errorMessage(self.error) : "no usable snapshot"; + throw new Error(`Terminal worker cannot read its own process identity: ${reason}`); + } + record = { + recordId: args.recordId, + pid: process.pid, + bootId: currentBootId(), + createdAt: new Date().toISOString(), + identity: { startedAt: self.snapshot.startedAt, commandLine: self.snapshot.commandLine }, + protocolVersion, + paseoVersion: args.paseoVersion, + socketPath, + secret, + runtime: args.runtime, + terminals: [], + }; + writeTerminalWorkerRecord(args.paseoHome, record); + reportReady(`ready ${socketPath}`); + + setTimeout(() => { + if (!everControlled && sessionsById.size === 0) exitWorker(0); + }, FIRST_CONTROLLER_TIMEOUT_MS).unref(); + setInterval(() => { + if (!controller && !existsSync(terminalWorkerRecordPath(args.paseoHome, process.pid))) { + void killEveryTerminalAndExit(); + } + }, ORPHAN_CHECK_INTERVAL_MS); + } + + process.on("SIGTERM", () => { + void killEveryTerminalAndExit(); + }); + + start().catch((error: unknown) => { + const message = errorMessage(error).replace(/\n/g, " | "); + console.error(`Terminal worker failed to start: ${errorMessage(error)}`); + reportReady(`error ${message}`); + exitWorker(1); + }); +} + +if (process.argv.includes(LAUNCH_ARG)) { + runLauncher(process.argv.slice(2)); +} else { + runWorker(process.argv.slice(2)); +} diff --git a/packages/server/src/terminal/terminal-worker-processes.ts b/packages/server/src/terminal/terminal-worker-processes.ts new file mode 100644 index 0000000000..b0c2e6d0cb --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-processes.ts @@ -0,0 +1,185 @@ +import { readFile } from "node:fs/promises"; +import { z } from "zod"; +import { execCommand } from "../utils/spawn.js"; +import { + createSystemManagedProcessTable, + type ManagedProcessCommandRunner, + type ManagedProcessInspection, + windowsPowerShellPath, +} from "../server/managed-processes/managed-processes.js"; + +// Process inspection for terminal worker ownership. Birth times are compared as the strings +// `ps -o lstart` and Win32_Process.CreationDate print, so every reader must print them the same +// way: `ps` follows LC_TIME, and the worker, the daemon, the supervisor and the CLI can run +// under different locales. Every `ps` here runs under LC_ALL=C. + +const PS_TIMEOUT_MS = 15_000; +const POSIX_LSTART_WIDTH = 24; + +export interface ProcessTableEntry { + pid: number; + ppid: number; + startedAt: string; + // POSIX session id where `ps` reports one (Linux). macOS `ps` has no session column. + sessionId: number | null; +} + +export interface TerminalWorkerProcessInspector { + /** True where the process table names each process's session (Linux). */ + readonly reportsSessions: boolean; + inspect(pid: number): Promise; + /** One snapshot of every process, for walking descendants. Throws when it cannot be read. */ + listProcesses(): Promise; + /** + * The environment a process was started with, as NAME=value entries; null when the process + * is gone. Throws when it cannot be read. Only called where `reportsSessions` is true. + */ + readEnvironment(pid: number): Promise; +} + +const WindowsProcessListSchema = z.array( + z.object({ + ProcessId: z.number().int(), + ParentProcessId: z.number().int().nullable().optional(), + CreationDate: z.string().nullable().optional(), + }), +); + +function createLocaleNeutralCommandRunner(): ManagedProcessCommandRunner { + return { + exec: (command, args) => + execCommand(command, args, { + envMode: "internal", + envOverlay: { LC_ALL: "C" }, + timeout: PS_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }).then(({ stdout, stderr }) => ({ stdout: String(stdout), stderr: String(stderr) })), + }; +} + +export function createTerminalWorkerProcessInspector( + options: { platform?: NodeJS.Platform; commandRunner?: ManagedProcessCommandRunner } = {}, +): TerminalWorkerProcessInspector { + const platform = options.platform ?? process.platform; + const commandRunner = options.commandRunner ?? createLocaleNeutralCommandRunner(); + const table = createSystemManagedProcessTable({ platform, commandRunner }); + return { + reportsSessions: platform === "linux", + inspect: (pid) => table.inspect(pid), + listProcesses: () => + platform === "win32" + ? listWindowsProcesses(commandRunner) + : listPosixProcesses(commandRunner, platform), + readEnvironment: (pid) => readLinuxEnvironment(platform, pid), + }; +} + +async function readLinuxEnvironment( + platform: NodeJS.Platform, + pid: number, +): Promise { + if (platform !== "linux") { + throw new Error(`reading a process environment is not supported on ${platform}`); + } + let raw: string; + try { + raw = await readFile(`/proc/${pid}/environ`, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return raw.split("\0").filter((entry) => entry.length > 0); +} + +async function listPosixProcesses( + commandRunner: ManagedProcessCommandRunner, + platform: NodeJS.Platform, +): Promise { + const withSession = platform === "linux"; + const columns = withSession + ? ["-o", "pid=", "-o", "ppid=", "-o", "sid=", "-o", "lstart="] + : ["-o", "pid=", "-o", "ppid=", "-o", "lstart="]; + const { stdout } = await commandRunner.exec("ps", ["-A", "-ww", ...columns]); + const entries: ProcessTableEntry[] = []; + const leadingInts = withSession ? 3 : 2; + for (const line of stdout.split("\n")) { + const match = line.match( + withSession ? /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/ : /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/, + ); + if (!match) continue; + const startedAt = match[leadingInts + 1].slice(0, POSIX_LSTART_WIDTH).trim(); + entries.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + sessionId: withSession ? Number(match[3]) : null, + startedAt, + }); + } + if (entries.length === 0) { + throw new Error("ps returned no processes"); + } + return entries; +} + +async function listWindowsProcesses( + commandRunner: ManagedProcessCommandRunner, +): Promise { + const command = + "@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate) | ConvertTo-Json -Compress"; + const { stdout } = await commandRunner.exec(windowsPowerShellPath(), [ + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ]); + const parsed: unknown = JSON.parse(stdout.trim()); + const rows = WindowsProcessListSchema.parse(Array.isArray(parsed) ? parsed : [parsed]); + return rows.flatMap((row) => + row.CreationDate + ? [ + { + pid: row.ProcessId, + ppid: row.ParentProcessId ?? 0, + startedAt: row.CreationDate, + sessionId: null, + }, + ] + : [], + ); +} + +/** + * Every process below `rootPid` in the snapshot, plus (where the platform reports sessions) + * every member of the session `rootPid` led and everything below those members. A PTY program + * is a session leader, and the kernel keeps its pid reserved as the session id while any member + * lives, so the session still names a HUP-ignoring job after the shell exits and the job is + * reparented. + */ +export function collectDescendants( + snapshot: ProcessTableEntry[], + rootPid: number, +): ProcessTableEntry[] { + const childrenByParent = new Map(); + for (const entry of snapshot) { + const siblings = childrenByParent.get(entry.ppid) ?? []; + siblings.push(entry); + childrenByParent.set(entry.ppid, siblings); + } + const found = new Map(); + const queue = [rootPid]; + for (const entry of snapshot) { + if (entry.sessionId === rootPid && entry.pid !== rootPid) { + found.set(entry.pid, entry); + queue.push(entry.pid); + } + } + while (queue.length > 0) { + const parent = queue.shift()!; + for (const child of childrenByParent.get(parent) ?? []) { + if (child.pid === rootPid || found.has(child.pid)) continue; + found.set(child.pid, child); + queue.push(child.pid); + } + } + return Array.from(found.values()); +} diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts new file mode 100644 index 0000000000..c3810bf9cf --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; + +describe("classifyTerminalWorkerProtocol", () => { + it.each([ + [5, "current"], + [4, "previous"], + [3, "obsolete"], + [-1, "obsolete"], + [6, "newer"], + // A version this daemon cannot read comes from a Paseo it does not know. + ["6", "newer"], + [4.5, "newer"], + [Number.NaN, "newer"], + [undefined, "newer"], + ])("a worker reporting %s to a daemon at 5 is %s", (reported, fit) => { + expect(classifyTerminalWorkerProtocol(reported, 5)).toBe(fit); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 5e7d1a30c0..86fc4a7772 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -31,6 +31,55 @@ export interface WorkerCreateTerminalOptions { cols?: number; activityToken?: string; activityUrl?: string | null; + // The worker outlives the daemon that started it, so the controlling daemon + // sends its own environment and Paseo CLI path with every create instead of + // letting the worker reuse whatever it inherited at launch. + baseEnv?: Record; + paseoCliPath?: string | null; +} + +// Bumped when a request, response or event changes shape. The frozen envelope never changes +// across versions: the frame format, `hello`/`helloAck`, the `listTerminals` info fields, and +// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new +// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer +// worker running untouched. See docs/terminal-worker.md. +export const TERMINAL_WORKER_PROTOCOL_VERSION = 1; + +// Test-only: the protocol version a worker reports, so tests can stand up older and newer +// workers from one build. +export const TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV = "PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE"; + +/** How a daemon at `current` treats a worker reporting `reported`. */ +export type TerminalWorkerProtocolFit = "current" | "previous" | "obsolete" | "newer"; + +export function classifyTerminalWorkerProtocol( + reported: unknown, + current: number = TERMINAL_WORKER_PROTOCOL_VERSION, +): TerminalWorkerProtocolFit { + // A version this daemon cannot read comes from a Paseo it does not know: treat it as newer. + if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer"; + if (reported === current) return "current"; + if (reported === current - 1) return "previous"; + return reported < current ? "obsolete" : "newer"; +} + +export interface TerminalWorkerHello { + type: "hello"; + secret: string; + protocolVersion: number; +} + +export interface TerminalWorkerHelloAck { + type: "helloAck"; + pid: number; + protocolVersion: number; +} + +export interface WorkerListedTerminal { + terminal: WorkerTerminalInfo; + state: TerminalState; + activityToken: string | null; + replayPreamble: string; } export interface WorkerKillAndWaitOptions { @@ -87,7 +136,12 @@ export type TerminalWorkerRequest = stripAnsi?: boolean; } | { - type: "killAll"; + type: "listTerminals"; + requestId: string; + } + | { + // Kill every terminal (waiting for each program to exit) and exit. + type: "shutdown"; requestId: string; } | { @@ -149,6 +203,8 @@ export type TerminalWorkerEvent = export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent; +export type TerminalWorkerListResult = WorkerListedTerminal[]; + export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult; // The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so // the parent can cache the input-mode preamble instead of re-deriving it. diff --git a/packages/server/src/terminal/terminal-worker-reaper.test.ts b/packages/server/src/terminal/terminal-worker-reaper.test.ts new file mode 100644 index 0000000000..a872325d31 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-reaper.test.ts @@ -0,0 +1,640 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Worker } from "node:worker_threads"; +import { isPlatform } from "../test-utils/platform.js"; +import { currentBootId } from "../server/pid-lock.js"; +import { + createTerminalWorkerProcessInspector, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { + readUnconfirmedTerminalCleanup, + reapTerminalWorkers, + retryUnconfirmedTerminalCleanup, + TerminalCleanupNotConfirmedError, +} from "./terminal-worker-reaper.js"; +import { + readTerminalWorkerRecord, + terminalCleanupRecordPath, + terminalWorkerDirectory, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; + +// Real throwaway processes stand in for workers and terminal programs, so every signal the +// reaper sends lands on a process this test started. Nothing here uses invented pids. + +const inspector = createTerminalWorkerProcessInspector(); +const children: ChildProcess[] = []; +const extraPids: number[] = []; +const temporaryDirs: string[] = []; + +function spawnIdle( + script = "setInterval(() => {}, 1e9)", + env: NodeJS.ProcessEnv = process.env, +): ChildProcess { + const child = spawn(process.execPath, ["-e", script], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + env, + }); + children.push(child); + return child; +} + +// A program with one child of its own in a separate process group, so only the descendant walk +// (not the program's group kill) can reach it. +async function spawnProgramWithChild(): Promise<{ program: ChildProcess; childPid: number }> { + const program = spawnIdle(` + const child = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { stdio: "ignore", detached: true }); + process.stdout.write(String(child.pid) + "\\n"); + setInterval(() => {}, 1e9); + `); + const childPid = await new Promise((resolve) => { + program.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim()))); + }); + extraPids.push(childPid); + return { program, childPid }; +} + +async function birthOf(pid: number): Promise { + const inspection = await inspector.inspect(pid); + if (inspection.status !== "alive" || !inspection.snapshot.startedAt) { + throw new Error(`cannot read birth time of ${pid}`); + } + return inspection.snapshot.startedAt; +} + +async function goneProcessPid(): Promise { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((resolve) => child.once("exit", resolve)); + return child.pid!; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntilGone(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (isAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +// A member of the terminal "t1" carries its id, as every program a terminal starts does. +function terminalEnv(terminalId: string): NodeJS.ProcessEnv { + return { ...process.env, PASEO_TERMINAL_ID: terminalId }; +} + +// A process's start environment: /proc where the real inspector reads it (Linux), `ps -E` on +// macOS, whose real inspector has no session walk and so never reads one. +function readEnvironment(pid: number): Promise { + if (process.platform === "linux") return inspector.readEnvironment(pid); + return new Promise((resolve) => { + execFile("ps", ["-E", "-ww", "-o", "command=", "-p", String(pid)], (error, stdout) => { + resolve(error ? null : stdout.trim().split(/\s+/)); + }); + }); +} + +// The real process table as a Linux `ps` would print it for one session: `memberPid` belongs to +// the session `sessionId` led, and every other process to none this test knows. macOS `ps` has +// no session column, so this is the only way to run the Linux branch here; the member is a real +// process the test started. `reusedRoot` adds a process that took the session id's pid. +function withSessionMember(input: { + memberPid: number; + sessionId: number; + reusedRoot?: boolean; + environmentUnreadable?: boolean; +}): TerminalWorkerProcessInspector { + return { + reportsSessions: true, + inspect: (pid) => inspector.inspect(pid), + listProcesses: async () => { + // A fresh snapshot each call, so it is rewritten in place. + const snapshot = await inspector.listProcesses(); + for (const entry of snapshot) { + entry.sessionId = entry.pid === input.memberPid ? input.sessionId : null; + } + if (input.reusedRoot) { + snapshot.push({ + pid: input.sessionId, + ppid: 1, + startedAt: "Fri Jan 1 00:00:00 2100", + sessionId: input.sessionId, + }); + } + return snapshot; + }, + readEnvironment: (pid) => + input.environmentUnreadable + ? Promise.reject(new Error("permission denied")) + : readEnvironment(pid), + }; +} + +// A record this process cannot open until another thread restores it, the way a worker's +// rename over its record ends a Windows sharing violation. The thread waits for `release()`, then +// restores the record while this thread is still inside the (synchronous) record read. +async function unreadableUntilReleased( + recordPath: string, +): Promise<{ release: () => void; restored: Promise }> { + chmodSync(recordPath, 0); + const signal = new Int32Array(new SharedArrayBuffer(8)); + const thread = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const signal = new Int32Array(workerData.signal); + parentPort.postMessage("ready"); + Atomics.wait(signal, 0, 0); + Atomics.wait(signal, 1, 0, 30); + require("node:fs").chmodSync(workerData.recordPath, 0o600); + `, + { eval: true, workerData: { recordPath, signal: signal.buffer } }, + ); + await new Promise((resolve) => thread.once("message", resolve)); + const restored = new Promise((resolve, reject) => { + thread.once("exit", () => resolve()); + thread.once("error", reject); + }); + return { + release: () => { + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + }, + restored, + }; +} + +function makeHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "tw-reaper-")); + temporaryDirs.push(home); + return home; +} + +function writeRecord( + home: string, + input: Partial & Pick, +): string { + const record: TerminalWorkerRecord = { + recordId: "record-under-test", + bootId: currentBootId(), + createdAt: new Date().toISOString(), + identity: { + startedAt: "Thu Jan 1 00:00:00 1970", + commandLine: "node terminal-worker --paseo-terminal-worker record-under-test", + }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: path.join(home, "missing.sock"), + secret: "not-a-real-secret", + terminals: [], + ...input, + }; + return writeTerminalWorkerRecord(home, record); +} + +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.pid && isAlive(child.pid)) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } + } + for (const pid of extraPids.splice(0)) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { + it("ends the programs a dead worker left, and their children, then retires the record", async () => { + const home = makeHome(); + const { program, childPid } = await spawnProgramWithChild(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(program.pid!); + await waitUntilGone(childPid); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(childPid)).toBe(false); + expect(result.signaled).toEqual(expect.arrayContaining([program.pid!, childPid])); + expect(existsSync(recordPath)).toBe(false); + }); + + it("never signals a live process that reuses the worker pid, but still ends its programs", async () => { + const home = makeHome(); + const reusedWorkerPid = spawnIdle().pid!; + const program = spawnIdle(); + writeRecord(home, { + pid: reusedWorkerPid, + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(program.pid!); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(reusedWorkerPid)).toBe(true); + expect(result.signaled).not.toContain(reusedWorkerPid); + }); + + it("leaves a program alive when its pid now belongs to a process with another birth time", async () => { + const home = makeHome(); + const unrelated = spawnIdle(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: unrelated.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + + expect(isAlive(unrelated.pid!)).toBe(true); + expect(existsSync(recordPath)).toBe(false); + }); + + it("retires a record from an earlier boot without signaling anything it names", async () => { + const home = makeHome(); + const program = spawnIdle(); + const recordPath = writeRecord(home, { + pid: spawnIdle().pid!, + bootId: null, + createdAt: "2000-01-01T00:00:00.000Z", + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector }); + + expect(isAlive(program.pid!)).toBe(true); + expect(existsSync(recordPath)).toBe(false); + }); + + it("keeps a record of what it could not inspect and reports cleanup unconfirmed", async () => { + const home = makeHome(); + const uninspectable = spawnIdle(); + const killable = spawnIdle(); + const failing: TerminalWorkerProcessInspector = { + reportsSessions: inspector.reportsSessions, + inspect: (pid) => + pid === uninspectable.pid + ? Promise.resolve({ status: "error", error: new Error("ps unavailable") }) + : inspector.inspect(pid), + listProcesses: () => inspector.listProcesses(), + readEnvironment: (pid) => inspector.readEnvironment(pid), + }; + const workerPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: workerPid, + terminals: [ + { id: "t1", pid: uninspectable.pid!, startedAt: await birthOf(uninspectable.pid!) }, + { id: "t2", pid: killable.pid!, startedAt: await birthOf(killable.pid!) }, + ], + }); + + let caught: unknown; + try { + await reapTerminalWorkers({ paseoHome: home, inspector: failing, confirmMs: 500 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unresolved).toEqual([ + expect.objectContaining({ pid: uninspectable.pid, reason: "inspection failed" }), + ]); + await waitUntilGone(killable.pid!); + expect(isAlive(killable.pid!)).toBe(false); + expect(isAlive(uninspectable.pid!)).toBe(true); + // The closing state goes to the cleanup record; the worker's own record is left as it was. + const kept = await readTerminalWorkerRecord( + terminalCleanupRecordPath(home, "record-under-test"), + ); + expect(kept.pid).toBe(workerPid); + expect(kept.closing?.unresolved.length).toBeGreaterThan(0); + expect(kept.terminals.map((entry) => entry.pid)).toEqual([uninspectable.pid]); + expect((await readTerminalWorkerRecord(recordPath)).closing).toBeUndefined(); + }); + + it("ends a session member its gone terminal program left behind, where sessions are reported", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: await birthOf(process.pid) }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }), + }); + + await waitUntilGone(member.pid!); + expect(isAlive(member.pid!)).toBe(false); + expect(result.signaled).toContain(member.pid); + expect(existsSync(recordPath)).toBe(false); + }); + + it("leaves alive a later session that reuses a gone terminal program's session id", async () => { + const home = makeHome(); + // Born after the recorded program, as any member of a later session is; only the missing + // terminal id tells it apart. + const member = spawnIdle(undefined, terminalEnv("another-terminal")); + const goneProgramPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }), + }); + + expect(isAlive(member.pid!)).toBe(true); + expect(result.signaled).toEqual([]); + expect(existsSync(recordPath)).toBe(false); + }); + + it("signals no session member it cannot identify, and keeps the cleanup unconfirmed", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + await expect( + reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ + memberPid: member.pid!, + sessionId: goneProgramPid, + environmentUnreadable: true, + }), + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + + expect(isAlive(member.pid!)).toBe(true); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([ + expect.objectContaining({ pid: goneProgramPid, reason: "inspection failed" }), + ]); + }); + + it("signals no session member whose environment carries no terminal id, and keeps the cleanup unconfirmed", async () => { + const home = makeHome(); + // A job started with a scrubbed environment (`env -i`) carries no terminal id at all: it + // may still be this terminal's, so it is neither signaled nor dismissed as unrelated. + const member = spawnIdle(undefined, {}); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + const scrubbedMember = withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect( + reapTerminalWorkers({ paseoHome: home, inspector: scrubbedMember }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect(isAlive(member.pid!)).toBe(true); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([ + expect.objectContaining({ + pid: goneProgramPid, + reason: "inspection failed", + detail: expect.stringContaining(`session member ${member.pid}`), + }), + ]); + } + expect(existsSync(terminalCleanupRecordPath(home, "record-under-test"))).toBe(true); + }); + + it("does not walk a gone terminal program's pid that another process took before the table was read", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ + memberPid: member.pid!, + sessionId: goneProgramPid, + reusedRoot: true, + }), + }); + + expect(isAlive(member.pid!)).toBe(true); + expect(result.signaled).toEqual([]); + }); + + // A kill cleanup that meets an unreadable worker record cannot check what it names and + // leaves nothing else behind, so status and retry must keep reporting it after a respawn. + it.each([ + ["cleanup", (home: string) => terminalCleanupRecordPath(home, "record-under-test")], + ["worker", (home: string) => path.join(terminalWorkerDirectory(home), "4242.json")], + ])("never reports an unreadable %s record as finished", async (_kind, recordPathIn) => { + const home = makeHome(); + mkdirSync(terminalWorkerDirectory(home), { recursive: true }); + const unreadablePath = recordPathIn(home); + writeFileSync(unreadablePath, "{ not json"); + + for (const attempt of [ + async () => await readUnconfirmedTerminalCleanup(home), + () => retryUnconfirmedTerminalCleanup(home), + () => reapTerminalWorkers({ paseoHome: home, inspector }), + ]) { + let caught: unknown; + try { + await attempt(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unreadableRecords).toEqual([ + expect.objectContaining({ path: unreadablePath }), + ]); + } + expect(existsSync(unreadablePath)).toBe(true); + }); + + it("signals nothing while the process table cannot be read, so a retry still ends the children", async () => { + const home = makeHome(); + const { program, childPid } = await spawnProgramWithChild(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + const tableUnavailable: TerminalWorkerProcessInspector = { + reportsSessions: inspector.reportsSessions, + inspect: (pid) => inspector.inspect(pid), + listProcesses: () => Promise.reject(new Error("ps timed out")), + readEnvironment: (pid) => inspector.readEnvironment(pid), + }; + + await expect( + reapTerminalWorkers({ paseoHome: home, inspector: tableUnavailable }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + + expect(isAlive(program.pid!)).toBe(true); + expect(isAlive(childPid)).toBe(true); + const kept = await readTerminalWorkerRecord( + terminalCleanupRecordPath(home, "record-under-test"), + ); + expect(kept.closing?.unresolved.length).toBeGreaterThan(0); + expect(kept.terminals.map((entry) => entry.pid)).toEqual([program.pid]); + + const retry = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + + await waitUntilGone(program.pid!); + await waitUntilGone(childPid); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(childPid)).toBe(false); + expect(retry.signaled).toEqual(expect.arrayContaining([program.pid!, childPid])); + expect(existsSync(recordPath)).toBe(false); + expect(existsSync(terminalCleanupRecordPath(home, "record-under-test"))).toBe(false); + }); + + it("escalates to SIGKILL a program that ignores SIGHUP and SIGTERM", async () => { + const home = makeHome(); + const stubborn = spawnIdle(` + process.on("SIGHUP", () => {}); + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1e9); + `); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: stubborn.pid!, startedAt: await birthOf(stubborn.pid!) }], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(stubborn.pid!); + expect(isAlive(stubborn.pid!)).toBe(false); + }); + + it.skipIf(process.getuid?.() === 0)( + "keeps the daemon event loop responsive while status retries an unreadable record", + async () => { + const home = makeHome(); + const recordPath = writeRecord(home, { pid: process.pid }); + chmodSync(recordPath, 0); + let serviced = false; + setImmediate(() => { + serviced = true; + }); + try { + await expect( + Promise.resolve().then(() => readUnconfirmedTerminalCleanup(home)), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect(serviced).toBe(true); + } finally { + chmodSync(recordPath, 0o600); + } + }, + ); + + // chmod cannot make a file unreadable to root. + describe.skipIf(process.getuid?.() === 0)("a record that cannot be opened for a moment", () => { + it("still ends a live worker it names, then retires the record", async () => { + const home = makeHome(); + const worker = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + { detached: true, stdio: "ignore" }, + ); + children.push(worker); + const recordPath = writeRecord(home, { + pid: worker.pid!, + identity: { + startedAt: await birthOf(worker.pid!), + commandLine: "node -e --paseo-terminal-worker record-under-test", + }, + }); + const record = await unreadableUntilReleased(recordPath); + + record.release(); + const result = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + await record.restored; + + await waitUntilGone(worker.pid!); + expect(isAlive(worker.pid!)).toBe(false); + expect(result.signaled).toContain(worker.pid!); + expect(existsSync(recordPath)).toBe(false); + }); + + it("confirms the cleanup of a worker that already exited, then retires the record", async () => { + const home = makeHome(); + const program = spawnIdle(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + const record = await unreadableUntilReleased(recordPath); + + record.release(); + const result = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + await record.restored; + + await waitUntilGone(program.pid!); + expect(isAlive(program.pid!)).toBe(false); + expect(result.retired).toBe(1); + expect(existsSync(recordPath)).toBe(false); + }); + + it("still reports a record that stays unreadable, and signals nothing it names", async () => { + const home = makeHome(); + const worker = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + { detached: true, stdio: "ignore" }, + ); + children.push(worker); + const recordPath = writeRecord(home, { + pid: worker.pid!, + identity: { + startedAt: await birthOf(worker.pid!), + commandLine: "node -e --paseo-terminal-worker record-under-test", + }, + }); + chmodSync(recordPath, 0); + + let caught: unknown; + try { + await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unreadableRecords).toEqual([ + expect.objectContaining({ path: recordPath, error: expect.stringContaining("EACCES") }), + ]); + expect(isAlive(worker.pid!)).toBe(true); + chmodSync(recordPath, 0o600); + expect(existsSync(recordPath)).toBe(true); + }); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-reaper.ts b/packages/server/src/terminal/terminal-worker-reaper.ts new file mode 100644 index 0000000000..2647d4b25e --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-reaper.ts @@ -0,0 +1,642 @@ +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import treeKill from "tree-kill"; +import { writtenDuringThisBoot } from "../server/pid-lock.js"; +import { connectTerminalWorker } from "./terminal-worker-client.js"; +import { pruneTerminalWorkerRuntimes } from "./terminal-worker-runtime.js"; +import { + classifyTerminalProgramProcess, + classifyTerminalWorkerProcess, + type ProcessOwnership, +} from "./terminal-worker-ownership.js"; +import { + collectDescendants, + createTerminalWorkerProcessInspector, + type ProcessTableEntry, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { + listTerminalWorkerRecords, + retireTerminalWorkerRecord, + terminalCleanupRecordPath, + writeTerminalCleanupRecord, + type TerminalCleanupUnresolved, + type TerminalWorkerRecord, + type TerminalWorkerRecordFile, + type TerminalWorkerTerminalEntry, +} from "./terminal-worker-record.js"; + +// The one kill path for terminal workers outside a connected daemon, and the one the daemon +// uses for its own kill stop. Every kill intent (stop, quit, --force, a kill restart whose +// shutdown hung, a dead or stopped worker, a program that ignores HUP) ends here: +// +// 1. Classify the worker and every terminal program of each record on its own, by birth time. +// A record from an earlier boot names only processes the reboot ended: retire it unsignaled. +// Descendants of owned programs (and, on Linux, members of their sessions) join the +// candidates from one process-table snapshot taken before anything is signaled. Once a +// program is gone, its session id can belong to a later session, so a member of a gone +// program's session joins only when its environment carries that terminal's id. When the +// table cannot be read, nothing in the record is signaled: ending a program would reparent +// the children only the table can find, and a retry could never reach them again. +// 2. An owned worker gets an authenticated `shutdown`: it kills each terminal and waits for it. +// 3. After `gracefulMs`, SIGKILL whatever owned process is left, re-inspecting right before +// each signal. +// 4. Succeed only when every candidate is gone or proven unrelated. Otherwise keep a record of +// the unresolved entries, marked closing, in a cleanup record no worker rewrites, and throw +// TerminalCleanupNotConfirmedError. An unreadable record is never reported confirmed. + +const DEFAULT_GRACEFUL_MS = 5_000; +const DEFAULT_CONFIRM_MS = 3_000; +const POLL_INTERVAL_MS = 100; + +export interface UnresolvedTerminalProcess extends TerminalCleanupUnresolved { + recordPath: string; +} + +// What a cleanup could not confirm, as a caller reports it: read here, or from the daemon +// status of a host the caller cannot read itself. +export interface UnconfirmedTerminalProcess { + pid: number; + role: string; + reason: string; + detail: string; +} + +export interface UnreadableTerminalWorkerRecord { + path: string; + error: string; +} + +export class TerminalCleanupNotConfirmedError extends Error { + readonly code = "TERMINAL_CLEANUP_NOT_CONFIRMED"; + readonly unresolved: UnconfirmedTerminalProcess[]; + readonly unreadableRecords: UnreadableTerminalWorkerRecord[]; + + constructor( + unresolved: UnconfirmedTerminalProcess[], + unreadableRecords: UnreadableTerminalWorkerRecord[] = [], + ) { + super( + [ + "Terminal cleanup was not confirmed.", + ...(unresolved.length > 0 + ? [ + "These processes may still be running:", + ...unresolved.map( + (entry) => ` pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`, + ), + "Their record is kept; the next `paseo daemon stop` retries the cleanup.", + ] + : []), + ...(unreadableRecords.length > 0 + ? [ + "These records cannot be read, so the processes they name cannot be checked:", + ...unreadableRecords.map((entry) => ` ${entry.path}: ${entry.error}`), + "Repair or remove each once you have checked its processes are gone.", + ] + : []), + ].join("\n"), + ); + this.name = "TerminalCleanupNotConfirmedError"; + this.unresolved = unresolved; + this.unreadableRecords = unreadableRecords; + } +} + +/** + * Retries the cleanup an earlier kill could not confirm (records marked closing), leaving any + * live worker a daemon controls alone. Throws TerminalCleanupNotConfirmedError when processes + * still survive, or when a record cannot be read, so a kill restart can report them. + */ +export async function retryUnconfirmedTerminalCleanup(paseoHome: string): Promise { + const files = await listTerminalWorkerRecords(paseoHome); + const recordPaths = files.flatMap((file) => + ("record" in file && file.record.closing) || "error" in file ? [file.path] : [], + ); + if (recordPaths.length === 0) return; + await reapTerminalWorkers({ paseoHome, gracefulMs: 0, recordPaths }); +} + +/** + * What the last cleanup of this home could not confirm gone, as its closing records name it. + * Read without retrying, so a daemon can report it (daemon status) to clients that cannot read + * this home themselves. Throws TerminalCleanupNotConfirmedError when any record cannot be read, + * worker records included: a kill cleanup that met it could not check what it names, and a + * failed cleanup leaves nothing else behind, so the cleanup cannot be reported finished. + */ +export async function readUnconfirmedTerminalCleanup( + paseoHome: string, +): Promise { + const files = await listTerminalWorkerRecords(paseoHome); + const unresolved = files.flatMap((file) => + "record" in file && file.record.closing + ? file.record.closing.unresolved.map((entry) => ({ ...entry, recordPath: file.path })) + : [], + ); + const unreadable = files.flatMap((file) => + "error" in file ? [{ path: file.path, error: file.error.message }] : [], + ); + if (unreadable.length > 0) { + throw new TerminalCleanupNotConfirmedError(unresolved, unreadable); + } + return unresolved; +} + +export interface TerminalWorkerReapResult { + records: number; + retired: number; + signaled: number[]; +} + +export interface ReapTerminalWorkersOptions { + paseoHome: string; + /** Time the worker gets to close its terminals before SIGKILL. 0 skips the shutdown request. */ + gracefulMs?: number; + confirmMs?: number; + /** Limit cleanup to these record files. Defaults to every record in the home. */ + recordPaths?: string[]; + inspector?: TerminalWorkerProcessInspector; + log?: (message: string, fields?: Record) => void; +} + +interface Candidate { + pid: number; + startedAt: string; + role: UnresolvedTerminalProcess["role"]; + terminalId: string; + state: ProcessOwnership; + // Set when this program's children could not be looked up: its cleanup is never reported + // confirmed, even once the program itself is gone, and its entry stays for the retry. + descendantsUnconfirmed: string | null; +} + +interface CandidateDiscovery { + candidates: Candidate[]; + // The process table could not be read, so no process of the record may be signaled yet. + tableError: string | null; +} + +export async function reapTerminalWorkers( + options: ReapTerminalWorkersOptions, +): Promise { + const inspector = options.inspector ?? createTerminalWorkerProcessInspector(); + const gracefulMs = options.gracefulMs ?? DEFAULT_GRACEFUL_MS; + const confirmMs = options.confirmMs ?? DEFAULT_CONFIRM_MS; + const log = options.log ?? (() => {}); + const wanted = options.recordPaths ? new Set(options.recordPaths) : null; + const result: TerminalWorkerReapResult = { records: 0, retired: 0, signaled: [] }; + const unresolved: UnresolvedTerminalProcess[] = []; + const unreadable: UnreadableTerminalWorkerRecord[] = []; + + for (const file of await listTerminalWorkerRecords(options.paseoHome)) { + if (wanted && !wanted.has(file.path)) continue; + result.records += 1; + if ("error" in file) { + // Nothing in an unreadable record can identify a process, so nothing may be signaled, + // and nothing it names can be confirmed gone. + log("Skipping unreadable terminal worker record", { + recordPath: file.path, + error: file.error.message, + }); + unreadable.push({ path: file.path, error: file.error.message }); + continue; + } + const outcome = await reapRecord({ + paseoHome: options.paseoHome, + file, + inspector, + gracefulMs, + confirmMs, + log, + }); + result.signaled.push(...outcome.signaled); + if (outcome.unresolved.length === 0) { + result.retired += 1; + } else { + unresolved.push(...outcome.unresolved); + } + } + + // Copies of worker files no remaining record names belong to no worker now. + const pruned = await pruneTerminalWorkerRuntimes(options.paseoHome); + for (const failure of pruned.failed) { + log("Cannot remove a terminal worker copy; the next cleanup retries", failure); + } + + if (unresolved.length > 0 || unreadable.length > 0) { + throw new TerminalCleanupNotConfirmedError(unresolved, unreadable); + } + return result; +} + +type ReadableRecordFile = Extract; + +interface ReapRecordInput { + paseoHome: string; + file: ReadableRecordFile; + inspector: TerminalWorkerProcessInspector; + gracefulMs: number; + confirmMs: number; + log: NonNullable; +} + +async function reapRecord( + input: ReapRecordInput, +): Promise<{ signaled: number[]; unresolved: UnresolvedTerminalProcess[] }> { + const { file, inspector, log } = input; + const { record } = file; + const recordPath = file.path; + if (!writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId })) { + await retireTerminalWorkerRecord(file); + log("Retired terminal worker record from an earlier boot", { recordPath }); + return { signaled: [], unresolved: [] }; + } + + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + const discovery = await collectCandidates(record, inspector, log); + const tracked: TrackedProcesses = { + record, + inspector, + worker, + candidates: discovery.candidates, + }; + + if (discovery.tableError !== null) { + return await keepUnresolved(input, tracked, [], `not signaled: ${discovery.tableError}`); + } + + if (tracked.worker.status === "owned" && input.gracefulMs > 0) { + await requestWorkerShutdown(record, input.gracefulMs, log); + } + await waitUntilSettled( + tracked, + Date.now() + (tracked.worker.status === "owned" ? input.gracefulMs : 0), + ); + + let signaled: number[] = []; + if (!isSettled(tracked)) { + signaled = await forceKillOwned(tracked, recordPath, log); + await waitUntilSettled(tracked, Date.now() + input.confirmMs); + } + + return await keepUnresolved(input, tracked, signaled, null); +} + +// Retires the record when every process it names is settled; otherwise writes the cleanup +// record, marked closing, with only the unresolved entries. A live worker keeps rewriting its +// own record, so the closing state never goes there. `notSignaled` explains open processes that +// were deliberately left alone. +async function keepUnresolved( + input: Pick, + tracked: TrackedProcesses, + signaled: number[], + notSignaled: string | null, +): Promise<{ signaled: number[]; unresolved: UnresolvedTerminalProcess[] }> { + const { record } = tracked; + const { file, log } = input; + const recordPath = terminalCleanupRecordPath(input.paseoHome, record.recordId); + const { unresolved, remaining } = collectUnresolved(tracked, recordPath, notSignaled); + if (unresolved.length === 0) { + await retireTerminalWorkerRecord(file); + log("Terminal worker record cleaned up", { recordPath: file.path, signaled }); + return { signaled, unresolved }; + } + await writeTerminalCleanupRecord(input.paseoHome, { + ...record, + terminals: remaining, + closing: { + unresolved: unresolved.map(({ pid, role, reason, detail }) => ({ + pid, + role, + reason, + detail, + })), + }, + }); + log("Terminal worker cleanup not confirmed; record kept", { + recordPath, + unresolved: unresolved.map((entry) => ({ pid: entry.pid, reason: entry.reason })), + }); + return { signaled, unresolved }; +} + +interface TrackedProcesses { + record: TerminalWorkerRecord; + inspector: TerminalWorkerProcessInspector; + worker: ProcessOwnership; + candidates: Candidate[]; +} + +function isOpen(state: ProcessOwnership): boolean { + return state.status === "owned" || state.status === "unknown"; +} + +function isSettled(tracked: TrackedProcesses): boolean { + return !isOpen(tracked.worker) && tracked.candidates.every((c) => !isOpen(c.state)); +} + +async function refreshTracked(tracked: TrackedProcesses): Promise { + if (isOpen(tracked.worker)) { + tracked.worker = classifyTerminalWorkerProcess( + tracked.record, + await tracked.inspector.inspect(tracked.record.pid), + ); + } + for (const candidate of tracked.candidates) { + if (isOpen(candidate.state)) { + candidate.state = classifyTerminalProgramProcess( + candidate, + await tracked.inspector.inspect(candidate.pid), + ); + } + } +} + +async function waitUntilSettled(tracked: TrackedProcesses, deadline: number): Promise { + await refreshTracked(tracked); + while (!isSettled(tracked) && Date.now() < deadline) { + await delay(POLL_INTERVAL_MS); + await refreshTracked(tracked); + } +} + +// SIGKILL every process still owned, re-inspecting each right before its signal. +async function forceKillOwned( + tracked: TrackedProcesses, + recordPath: string, + log: NonNullable, +): Promise { + const { record, inspector } = tracked; + const signaled: number[] = []; + if (tracked.worker.status === "owned") { + tracked.worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (tracked.worker.status === "owned") { + await forceKillWorker(record.pid); + signaled.push(record.pid); + log("Force-killed terminal worker", { pid: record.pid, recordPath }); + } + } + for (const candidate of tracked.candidates) { + if (candidate.state.status !== "owned") continue; + candidate.state = classifyTerminalProgramProcess( + candidate, + await inspector.inspect(candidate.pid), + ); + if (candidate.state.status !== "owned") continue; + forceKillProgram(candidate.pid); + signaled.push(candidate.pid); + log("Force-killed terminal program", { + pid: candidate.pid, + terminalId: candidate.terminalId, + recordPath, + }); + } + return signaled; +} + +function collectUnresolved( + tracked: TrackedProcesses, + recordPath: string, + notSignaled: string | null, +): { unresolved: UnresolvedTerminalProcess[]; remaining: TerminalWorkerTerminalEntry[] } { + const unresolved: UnresolvedTerminalProcess[] = []; + if (isOpen(tracked.worker)) { + unresolved.push( + describeUnresolved({ + pid: tracked.record.pid, + role: "worker", + state: tracked.worker, + recordPath, + notSignaled, + }), + ); + } + const remaining: TerminalWorkerTerminalEntry[] = []; + for (const candidate of tracked.candidates) { + const stillOpen = isOpen(candidate.state); + if (!stillOpen && candidate.descendantsUnconfirmed === null) continue; + unresolved.push( + stillOpen + ? describeUnresolved({ + pid: candidate.pid, + role: candidate.role, + state: candidate.state, + recordPath, + notSignaled, + }) + : { + pid: candidate.pid, + role: candidate.role, + reason: "inspection failed", + detail: candidate.descendantsUnconfirmed!, + recordPath, + }, + ); + remaining.push({ + id: candidate.terminalId, + pid: candidate.pid, + startedAt: candidate.startedAt, + }); + } + return { unresolved, remaining }; +} + +async function collectCandidates( + record: TerminalWorkerRecord, + inspector: TerminalWorkerProcessInspector, + log: NonNullable, +): Promise { + const candidates = new Map(); + for (const entry of record.terminals) { + candidates.set(entry.pid, { + pid: entry.pid, + startedAt: entry.startedAt, + role: "terminal program", + terminalId: entry.id, + state: classifyTerminalProgramProcess(entry, await inspector.inspect(entry.pid)), + descendantsUnconfirmed: null, + }); + } + // Owned programs are walked through the table. Where sessions are reported, so are programs + // already gone: the kernel keeps a session id reserved while a member lives, so a job the + // shell left behind is still found. A pid reused by another process (unrelated) leads someone + // else's session and is never walked. + const roots = Array.from(candidates.values()).filter( + (c) => c.state.status === "owned" || (c.state.status === "gone" && inspector.reportsSessions), + ); + if (roots.length === 0) return { candidates: Array.from(candidates.values()), tableError: null }; + + let snapshot: ProcessTableEntry[]; + try { + snapshot = await inspector.listProcesses(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log("Cannot list processes for terminal cleanup", { error: message }); + for (const root of roots) { + root.descendantsUnconfirmed = `its child processes could not be listed: ${message}`; + } + return { + candidates: Array.from(candidates.values()), + tableError: `process table unavailable: ${message}`, + }; + } + for (const root of roots) { + const inTable = snapshot.find((entry) => entry.pid === root.pid); + if (inTable && inTable.startedAt !== root.startedAt) { + // The program ended and its pid was reused before the snapshot: what is below that pid + // now belongs to someone else, and the old session ended with every member of it. + log("Not walking a terminal program pid reused before the process table was read", { + pid: root.pid, + terminalId: root.terminalId, + }); + continue; + } + if (!inTable && !inspector.reportsSessions) continue; + // Below a live program its parent links prove ownership. Once it is gone, its session id + // may have been reused by a later session, so each member must carry the terminal's id. + const rootGone = !inTable; + for (const descendant of collectDescendants(snapshot, root.pid)) { + if (candidates.has(descendant.pid)) continue; + if (rootGone) { + const proof = await proveTerminalMember(inspector, descendant.pid, root.terminalId); + if (proof.status === "gone") continue; + if (proof.status === "unknown") { + root.descendantsUnconfirmed = `session member ${descendant.pid} cannot be identified: ${proof.reason}`; + continue; + } + if (proof.status === "unrelated") { + log("Ignoring a session member that does not carry its terminal's id", { + pid: descendant.pid, + terminalId: root.terminalId, + }); + continue; + } + } + candidates.set(descendant.pid, { + pid: descendant.pid, + startedAt: descendant.startedAt, + role: "terminal program", + terminalId: root.terminalId, + state: { status: "owned" }, + descendantsUnconfirmed: null, + }); + } + } + return { candidates: Array.from(candidates.values()), tableError: null }; +} + +// A terminal's programs inherit PASEO_TERMINAL_ID (terminal-manager.ts) from its PTY program. +// A process in a later session that happens to reuse the session id does not carry it. +async function proveTerminalMember( + inspector: TerminalWorkerProcessInspector, + pid: number, + terminalId: string, +): Promise { + let environment: string[] | null; + try { + environment = await inspector.readEnvironment(pid); + } catch (error) { + return { + status: "unknown", + reason: `environment unreadable: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (environment === null) return { status: "gone" }; + if (environment.includes(`PASEO_TERMINAL_ID=${terminalId}`)) return { status: "owned" }; + // Another terminal's id proves the process belongs elsewhere. No id at all proves nothing: a + // job started with a scrubbed environment (`env -i`) can still be this terminal's. + return environment.some((entry) => entry.startsWith("PASEO_TERMINAL_ID=")) + ? { status: "unrelated", reason: "environment carries another terminal's id" } + : { status: "unknown", reason: "its environment carries no terminal id" }; +} + +async function requestWorkerShutdown( + record: TerminalWorkerRecord, + gracefulMs: number, + log: NonNullable, +): Promise { + const deadline = Date.now() + gracefulMs; + let connection; + try { + connection = await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + timeoutMs: Math.min(gracefulMs, 2_000), + }); + } catch (error) { + // A worker that cannot be reached is escalated by birth time below; a failed connection + // alone never authorizes a signal. + log("Terminal worker did not accept a shutdown request", { + pid: record.pid, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + const responded = new Promise((resolve) => { + connection.on("message", (message) => { + if (message.type === "response") resolve(); + }); + }); + const closed = new Promise((resolve) => connection.on("close", () => resolve())); + const sendFailed = new Promise((resolve) => { + connection.send({ type: "shutdown", requestId: randomUUID() }, (error) => { + if (error) resolve(); + }); + }); + const timeout = new AbortController(); + await Promise.race([ + responded, + closed, + sendFailed, + delay(Math.max(0, deadline - Date.now()), undefined, { signal: timeout.signal }).catch( + () => undefined, + ), + ]); + timeout.abort(); + connection.disconnect(); +} + +function forceKillWorker(pid: number): Promise { + if (process.platform === "win32") { + return new Promise((resolve) => { + treeKill(pid, "SIGKILL", () => resolve()); + }); + } + signalIgnoringMissing(pid, "SIGKILL"); + return Promise.resolve(); +} + +function forceKillProgram(pid: number): void { + if (process.platform !== "win32") { + // A PTY program leads its own session and process group. The group cannot outlive its + // leader's pid while any member is alive, so it holds nothing but this program's job. + signalIgnoringMissing(-pid, "SIGKILL"); + } + signalIgnoringMissing(pid, "SIGKILL"); +} + +function signalIgnoringMissing(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") throw error; + } +} + +function describeUnresolved(input: { + pid: number; + role: UnresolvedTerminalProcess["role"]; + state: ProcessOwnership; + recordPath: string; + notSignaled: string | null; +}): UnresolvedTerminalProcess { + const { pid, role, state, recordPath, notSignaled } = input; + if (state.status === "unknown") { + return { pid, role, reason: "inspection failed", detail: state.reason, recordPath }; + } + if (notSignaled !== null) { + return { pid, role, reason: "inspection failed", detail: notSignaled, recordPath }; + } + return { pid, role, reason: "still alive", detail: "survived SIGKILL", recordPath }; +} diff --git a/packages/server/src/terminal/terminal-worker-record.ts b/packages/server/src/terminal/terminal-worker-record.ts new file mode 100644 index 0000000000..3176d2ee8e --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record.ts @@ -0,0 +1,230 @@ +import { readdir, readFile, rm } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writePrivateFileAtomicSync, writePrivateFileAtomic } from "../server/private-files.js"; + +// Owner-only metadata a detached terminal worker leaves in $PASEO_HOME so the next daemon can +// find, authenticate and adopt it, and so cleanup can identify every process it is allowed to +// signal. Process identity is the birth time `ps`/Win32_Process report, never the pid alone. + +const TerminalWorkerTerminalEntrySchema = z.object({ + id: z.string().min(1), + pid: z.number().int().positive(), + startedAt: z.string().min(1), +}); + +// One process a cleanup could not confirm gone, kept so a later caller (a retry, or a remote +// client reading daemon status) can name it. +const TerminalCleanupUnresolvedSchema = z.object({ + pid: z.number().int().positive(), + role: z.enum(["worker", "terminal program"]), + reason: z.enum(["still alive", "inspection failed"]), + detail: z.string(), +}); + +const TerminalWorkerRecordSchema = z.object({ + recordId: z.string().min(1), + pid: z.number().int().positive(), + bootId: z.string().nullable(), + createdAt: z.string().min(1), + identity: z.object({ + startedAt: z.string().min(1), + commandLine: z.string().min(1), + }), + // Read loosely: a version this daemon cannot interpret marks a worker from a newer Paseo + // (classifyTerminalWorkerProtocol), not an unreadable record. + protocolVersion: z.union([z.number(), z.string()]), + paseoVersion: z.string(), + socketPath: z.string().min(1), + secret: z.string().min(1), + // The copy of the worker's files it runs from (a directory name under terminal-worker/), or + // null when it runs from source. A copy named by any record is never deleted. + runtime: z.string().nullable().optional(), + terminals: z.array(TerminalWorkerTerminalEntrySchema), + // Set only in a cleanup record (below), with what a cleanup could not confirm gone. A record + // marked closing is never adopted: the next daemon or stop retries the cleanup instead. + closing: z.object({ unresolved: z.array(TerminalCleanupUnresolvedSchema).min(1) }).optional(), +}); + +export type TerminalWorkerTerminalEntry = z.infer; +export type TerminalCleanupUnresolved = z.infer; +export type TerminalWorkerRecord = z.infer; + +// A worker record (`.json`) belongs to the worker, which rewrites it from memory whenever +// a terminal starts or exits. A cleanup that cannot confirm every process gone therefore keeps +// its state in a cleanup record of its own (`closing-.json`) that no worker writes, so +// the kill intent and what it could not confirm survive any later worker rewrite. +type TerminalWorkerRecordKind = "worker" | "cleanup"; + +export type TerminalWorkerRecordFile = + | { + // The file holding this record's state: the cleanup record when there is one. + path: string; + record: TerminalWorkerRecord; + // The worker's own record file, when it still exists. + workerRecordPath: string | null; + } + | { path: string; error: Error }; + +// The worker's command line carries this marker followed by its record id, so a reused pid +// running some other process (even another worker) never matches the record. +export const TERMINAL_WORKER_MARKER_ARG = "--paseo-terminal-worker"; + +export function terminalWorkerDirectory(paseoHome: string): string { + return path.join(paseoHome, "terminal-worker"); +} + +export function terminalWorkerRecordPath(paseoHome: string, workerPid: number): string { + return path.join(terminalWorkerDirectory(paseoHome), `${workerPid}.json`); +} + +export function terminalCleanupRecordPath(paseoHome: string, recordId: string): string { + return path.join(terminalWorkerDirectory(paseoHome), `closing-${recordId}.json`); +} + +export function terminalWorkerLogPath(paseoHome: string): string { + return path.join(paseoHome, "terminal-worker.log"); +} + +export function writeTerminalWorkerRecord(paseoHome: string, record: TerminalWorkerRecord): string { + const recordPath = terminalWorkerRecordPath(paseoHome, record.pid); + writePrivateFileAtomicSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); + return recordPath; +} + +/** Writes the state of a cleanup that could not confirm every process gone. */ +export async function writeTerminalCleanupRecord( + paseoHome: string, + record: TerminalWorkerRecord & { closing: NonNullable }, +): Promise { + if (!RECORD_ID_PATTERN.test(record.recordId)) { + throw new Error(`Terminal worker record id is not a file name: ${record.recordId}`); + } + const recordPath = terminalCleanupRecordPath(paseoHome, record.recordId); + await writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`); + return recordPath; +} + +export async function readTerminalWorkerRecord(recordPath: string): Promise { + return TerminalWorkerRecordSchema.parse(JSON.parse(await readRecordFile(recordPath))); +} + +// A worker rewrites its record by renaming a new file over it. On Windows an open() that races +// that rename fails with EPERM (or EBUSY) for a moment, so such an error is retried for a short +// window before the record counts as unreadable. Reads and retries yield so daemon status and +// cleanup never block unrelated requests while another process holds the record open. +const TRANSIENT_READ_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]); +const TRANSIENT_READ_WINDOW_MS = 250; +const TRANSIENT_READ_PAUSE_MS = 10; + +async function readRecordFile(recordPath: string): Promise { + const deadline = Date.now() + TRANSIENT_READ_WINDOW_MS; + for (;;) { + try { + return await readFile(recordPath, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !TRANSIENT_READ_ERRORS.has(code) || Date.now() >= deadline) throw error; + await delay(TRANSIENT_READ_PAUSE_MS); + } + } +} + +const RECORD_ID_PATTERN = /^[A-Za-z0-9-]+$/; +const WORKER_RECORD_NAME = /^\d+\.json$/; +const CLEANUP_RECORD_NAME = /^closing-[A-Za-z0-9-]+\.json$/; + +function recordKindOf(fileName: string): TerminalWorkerRecordKind | null { + if (WORKER_RECORD_NAME.test(fileName)) return "worker"; + if (CLEANUP_RECORD_NAME.test(fileName)) return "cleanup"; + return null; +} + +/** + * Every record in the home. A cleanup record is merged with the worker record of the same + * record id: its closing state wins, and terminals the worker recorded since join it, so the + * retry covers everything the worker still runs. + */ +export async function listTerminalWorkerRecords( + paseoHome: string, +): Promise { + const directory = terminalWorkerDirectory(paseoHome); + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const errors: TerminalWorkerRecordFile[] = []; + const workers: Array<{ path: string; record: TerminalWorkerRecord }> = []; + const cleanups: Array<{ path: string; record: TerminalWorkerRecord }> = []; + for (const name of names.sort()) { + const kind = recordKindOf(name); + if (!kind) continue; + const recordPath = path.join(directory, name); + try { + const record = await readTerminalWorkerRecord(recordPath); + (kind === "worker" ? workers : cleanups).push({ path: recordPath, record }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + errors.push({ + path: recordPath, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + const files: TerminalWorkerRecordFile[] = []; + const merged = new Set(); + for (const cleanup of cleanups) { + const worker = workers.find( + (entry) => + entry.record.recordId === cleanup.record.recordId && + entry.record.pid === cleanup.record.pid, + ); + if (worker) merged.add(worker.path); + const known = new Set(cleanup.record.terminals.map((entry) => `${entry.id}:${entry.pid}`)); + const added = (worker?.record.terminals ?? []).filter( + (entry) => !known.has(`${entry.id}:${entry.pid}`), + ); + files.push({ + path: cleanup.path, + record: { ...cleanup.record, terminals: [...cleanup.record.terminals, ...added] }, + workerRecordPath: worker?.path ?? null, + }); + } + for (const worker of workers) { + if (merged.has(worker.path)) continue; + files.push({ path: worker.path, record: worker.record, workerRecordPath: worker.path }); + } + return [...files, ...errors]; +} + +/** + * Deletes a record, the worker's own record file and the socket it names, unless a later worker + * that reused the pid wrote that file since. The pipe namespace on Windows needs no unlink. + */ +export async function retireTerminalWorkerRecord(file: { + path: string; + record: TerminalWorkerRecord; + workerRecordPath: string | null; +}): Promise { + if (file.path !== file.workerRecordPath) await rm(file.path, { force: true }); + if (file.workerRecordPath) { + if (await workerRecordNamesAnother(file.workerRecordPath, file.record.recordId)) return; + await rm(file.workerRecordPath, { force: true }); + } + if (process.platform !== "win32") { + await rm(file.record.socketPath, { force: true }); + } +} + +// True when the file now belongs to another worker, or cannot be read to tell. +async function workerRecordNamesAnother(recordPath: string, recordId: string): Promise { + try { + return (await readTerminalWorkerRecord(recordPath)).recordId !== recordId; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} diff --git a/packages/server/src/terminal/terminal-worker-runtime.test.ts b/packages/server/src/terminal/terminal-worker-runtime.test.ts new file mode 100644 index 0000000000..feb4bf3309 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-runtime.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { builtinModules } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isPlatform } from "../test-utils/platform.js"; +import { + prepareTerminalWorkerRuntime, + pruneTerminalWorkerRuntimes, + TerminalWorkerRuntimeError, +} from "./terminal-worker-runtime.js"; +import { + terminalWorkerDirectory, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function makeDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +// A built bundle directory and a node-pty package shaped like the real ones. +function makeSources(bundleSource = "export {};\n"): { + bundleDirectory: string; + nodePtyDirectory: string; +} { + const bundleDirectory = makeDir("tw-bundle-"); + writeFileSync(path.join(bundleDirectory, "terminal-worker.mjs"), bundleSource); + mkdirSync(path.join(bundleDirectory, "shell-integration", "zsh"), { recursive: true }); + writeFileSync(path.join(bundleDirectory, "shell-integration", "zsh", ".zshenv"), "# zsh\n"); + const nodePtyDirectory = makeDir("tw-node-pty-"); + writeFileSync(path.join(nodePtyDirectory, "package.json"), '{"version":"1.2.3"}\n'); + mkdirSync(path.join(nodePtyDirectory, "lib")); + writeFileSync(path.join(nodePtyDirectory, "lib", "index.js"), "module.exports = {};\n"); + const native = path.join(nodePtyDirectory, "prebuilds", `${process.platform}-${process.arch}`); + mkdirSync(native, { recursive: true }); + writeFileSync(path.join(native, "pty.node"), "native"); + writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 }); + // Another platform's build is not copied. + mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform")); + return { bundleDirectory, nodePtyDirectory }; +} + +function recordNaming(runtime: string | null, pid = 4242): TerminalWorkerRecord { + return { + recordId: `record-${pid}`, + pid, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "1.0.0", + socketPath: "/tmp/does-not-exist.sock", + secret: "secret", + runtime, + terminals: [], + }; +} + +describe("terminal worker runtime copy", () => { + it("copies the bundle, its shell integration and this platform's node-pty into the home", () => { + const home = makeDir("tw-runtime-home-"); + const entry = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0-beta.1", + ...makeSources(), + }); + + const directory = path.join(terminalWorkerDirectory(home), entry.runtime!); + expect(entry.runtime).toMatch(/^1\.0\.0-beta\.1-[0-9a-f]{12}$/); + expect(entry.scriptPath).toBe(path.join(directory, "terminal-worker.mjs")); + expect(entry.execArgv).toEqual([]); + expect(existsSync(path.join(directory, "shell-integration", "zsh", ".zshenv"))).toBe(true); + const nodePty = path.join(directory, "node_modules", "node-pty"); + expect(existsSync(path.join(nodePty, "lib", "index.js"))).toBe(true); + expect(readdirSync(path.join(nodePty, "prebuilds"))).toEqual([ + `${process.platform}-${process.arch}`, + ]); + if (!isPlatform("win32")) { + const helper = path.join(nodePty, "prebuilds", `${process.platform}-${process.arch}`); + expect(statSync(path.join(helper, "spawn-helper")).mode & 0o111).toBe(0o111); + } + }); + + it("reuses a finished copy of the same content and makes a new one for a different build", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + const first = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const marker = path.join(terminalWorkerDirectory(home), first.runtime!, "terminal-worker.mjs"); + writeFileSync(marker, "// the running copy\n"); + + const again = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const rebuilt = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources("export const rebuilt = true;\n"), + }); + + expect(again.runtime).toBe(first.runtime); + expect(statSync(marker).size).toBe("// the running copy\n".length); + expect(rebuilt.runtime).not.toBe(first.runtime); + }); + + it("replaces a copy an earlier run left unfinished", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + const first = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const directory = path.join(terminalWorkerDirectory(home), first.runtime!); + rmSync(directory, { recursive: true }); + mkdirSync(directory); + + prepareTerminalWorkerRuntime({ paseoHome: home, paseoVersion: "1.0.0", ...sources }); + + expect(existsSync(path.join(directory, "terminal-worker.mjs"))).toBe(true); + }); + + it("fails before copying anything when node-pty has no native build for this platform", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + rmSync(path.join(sources.nodePtyDirectory, "prebuilds"), { recursive: true }); + + expect(() => + prepareTerminalWorkerRuntime({ paseoHome: home, paseoVersion: "1.0.0", ...sources }), + ).toThrow(TerminalWorkerRuntimeError); + expect(readdirSync(terminalWorkerDirectory(home))).toEqual([]); + }); + + it("removes only copies no worker record names", async () => { + const home = makeDir("tw-runtime-home-"); + const used = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources(), + }); + const unused = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "0.9.0", + ...makeSources(), + }); + writeTerminalWorkerRecord(home, recordNaming(used.runtime)); + const socketDirectory = path.join(terminalWorkerDirectory(home), "sock"); + mkdirSync(socketDirectory); + + const result = await pruneTerminalWorkerRuntimes(home); + + expect(result.removed).toEqual([path.join(terminalWorkerDirectory(home), unused.runtime!)]); + expect(existsSync(path.dirname(used.scriptPath))).toBe(true); + expect(existsSync(socketDirectory)).toBe(true); + }); + + it("removes nothing while a record cannot be read", async () => { + const home = makeDir("tw-runtime-home-"); + const copy = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources(), + }); + writeFileSync(path.join(terminalWorkerDirectory(home), "4242.json"), "{ not json"); + + expect((await pruneTerminalWorkerRuntimes(home)).removed).toEqual([]); + expect(existsSync(copy.scriptPath)).toBe(true); + }); +}); + +describe("terminal worker bundle", () => { + // The copy holds the bundle and node-pty only. A package the bundle loads at run time + // (a require hidden from esbuild behind createRequire) would be missing from it. + it("loads nothing at run time but Node built-ins and node-pty", async () => { + const outDir = makeDir("tw-bundle-build-"); + const script = fileURLToPath( + new URL("../../scripts/build-terminal-worker.mjs", import.meta.url), + ); + const { buildTerminalWorkerBundle } = (await import(script)) as { + buildTerminalWorkerBundle(input: { outDir: string }): Promise; + }; + await buildTerminalWorkerBundle({ outDir }); + + const bundle = readFileSync(path.join(outDir, "terminal-worker.mjs"), "utf8"); + const loaded = new Set(); + for (const match of bundle.matchAll( + /(?:\brequire\w*(?:\.resolve)?\(|\bfrom\s+|\bimport\s*\()\s*"([^"]+)"/g, + )) { + loaded.add(match[1]!); + } + const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + const packages = [...loaded].filter((specifier) => !builtins.has(specifier)); + expect(packages.map((specifier) => specifier.split("/")[0])).toEqual( + packages.map(() => "node-pty"), + ); + expect(existsSync(path.join(outDir, "shell-integration", "zsh"))).toBe(true); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-runtime.ts b/packages/server/src/terminal/terminal-worker-runtime.ts new file mode 100644 index 0000000000..7ee0696478 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-runtime.ts @@ -0,0 +1,249 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { access, readdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { listTerminalWorkerRecords, terminalWorkerDirectory } from "./terminal-worker-record.js"; + +// A terminal worker outlives the installation that started it. An npm or desktop update +// replaces that installation's files, so a worker launched from them would fail its next +// terminal create (node-pty's spawn-helper, the zsh integration). A built daemon therefore +// copies the worker bundle, its shell integration and node-pty into +// $PASEO_HOME/terminal-worker/-/ and launches the worker from there. +// The hash keeps two builds of one version (a local rebuild) from sharing a copy. A copy is +// removed once no worker record names it: after a worker drains, after a kill cleanup, and at +// daemon start. Dev runs (.ts source) launch from source and copy nothing. + +export const TERMINAL_WORKER_BUNDLE_NAME = "terminal-worker.mjs"; +// Written last into a finished copy; a directory without it is an interrupted copy. +const RUNTIME_MARKER = "paseo-terminal-worker-runtime.json"; +const TEMP_PREFIX = ".tmp-"; + +export class TerminalWorkerRuntimeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerRuntimeError"; + } +} + +/** What the launcher runs: the worker script, the node flags it needs, and the copy it is in. */ +export interface TerminalWorkerEntry { + scriptPath: string; + execArgv: string[]; + /** The copy's directory name under terminal-worker/, or null when run from source. */ + runtime: string | null; +} + +function sourceExecArgv(): string[] { + const loaderUrl = new URL("./terminal-ts-loader.mjs", import.meta.url).href; + const importSource = [ + 'import { register } from "node:module";', + 'import { pathToFileURL } from "node:url";', + `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, + ].join(" "); + return [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(importSource)}`, + ]; +} + +/** + * The entry a daemon launches its worker from: the source file in dev, otherwise a copy of the + * built bundle in this home, prepared on demand. + */ +export function resolveTerminalWorkerEntry(input: { + paseoHome: string; + paseoVersion: string; +}): TerminalWorkerEntry { + if (import.meta.url.endsWith(".ts")) { + return { + scriptPath: fileURLToPath(new URL("./terminal-worker-process.ts", import.meta.url)), + execArgv: sourceExecArgv(), + runtime: null, + }; + } + const bundleDirectory = fileURLToPath(new URL("../terminal-worker/", import.meta.url)); + if (!existsSync(path.join(bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME))) { + throw new TerminalWorkerRuntimeError( + `The terminal worker bundle is missing from ${bundleDirectory}; rebuild @getpaseo/server (npm run build).`, + ); + } + const require = createRequire(import.meta.url); + const nodePtyDirectory = path.dirname(require.resolve("node-pty/package.json")); + return prepareTerminalWorkerRuntime({ + paseoHome: input.paseoHome, + paseoVersion: input.paseoVersion, + bundleDirectory, + nodePtyDirectory, + }); +} + +function runtimeName(input: { + paseoVersion: string; + bundleDirectory: string; + nodePtyDirectory: string; +}): string { + const hash = createHash("sha256"); + hash.update(readFileSync(path.join(input.bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME))); + hash.update(readFileSync(path.join(input.nodePtyDirectory, "package.json"))); + hash.update(`${process.platform}-${process.arch}`); + const version = input.paseoVersion.replace(/[^A-Za-z0-9._-]/g, "_"); + return `${version}-${hash.digest("hex").slice(0, 12)}`; +} + +// Plain fs calls on purpose: inside the desktop app the sources live in app.asar, which +// Electron's fs reads transparently but newer helpers like cpSync do not. +function copyTree(source: string, destination: string): void { + const stat = statSync(source); + if (stat.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const name of readdirSync(source)) { + copyTree(path.join(source, name), path.join(destination, name)); + } + return; + } + writeFileSync(destination, readFileSync(source)); + // Keeps node-pty's spawn-helper executable. + chmodSync(destination, stat.mode & 0o777); +} + +function copyNodePty(source: string, destination: string): void { + mkdirSync(destination, { recursive: true }); + copyTree(path.join(source, "package.json"), path.join(destination, "package.json")); + copyTree(path.join(source, "lib"), path.join(destination, "lib")); + const native = [ + path.join("build", "Release"), + path.join("prebuilds", `${process.platform}-${process.arch}`), + ].filter((relative) => existsSync(path.join(source, relative))); + if (native.length === 0) { + throw new TerminalWorkerRuntimeError( + `node-pty at ${source} has no native build for ${process.platform}-${process.arch}`, + ); + } + for (const relative of native) { + copyTree(path.join(source, relative), path.join(destination, relative)); + } +} + +function isCompleteRuntime(directory: string): boolean { + return existsSync(path.join(directory, RUNTIME_MARKER)); +} + +/** + * Copies the worker bundle, its shell integration and node-pty into this home, unless a finished + * copy of the same content is already there. Returns the entry to launch from the copy. + */ +export function prepareTerminalWorkerRuntime(input: { + paseoHome: string; + paseoVersion: string; + bundleDirectory: string; + nodePtyDirectory: string; +}): TerminalWorkerEntry { + const root = terminalWorkerDirectory(input.paseoHome); + ensurePrivateDirectory(root); + const name = runtimeName(input); + const directory = path.join(root, name); + const entry = { + scriptPath: path.join(directory, TERMINAL_WORKER_BUNDLE_NAME), + execArgv: [], + runtime: name, + }; + if (isCompleteRuntime(directory)) return entry; + + // An interrupted copy left behind: no worker can be running from it. + rmSync(directory, { recursive: true, force: true }); + const temporary = path.join(root, `${TEMP_PREFIX}${name}-${randomUUID()}`); + try { + mkdirSync(temporary, { recursive: true }); + copyTree( + path.join(input.bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME), + path.join(temporary, TERMINAL_WORKER_BUNDLE_NAME), + ); + copyTree( + path.join(input.bundleDirectory, "shell-integration"), + path.join(temporary, "shell-integration"), + ); + copyNodePty(input.nodePtyDirectory, path.join(temporary, "node_modules", "node-pty")); + writeFileSync( + path.join(temporary, RUNTIME_MARKER), + `${JSON.stringify({ paseoVersion: input.paseoVersion, copiedAt: new Date().toISOString() })}\n`, + ); + renameSync(temporary, directory); + } catch (error) { + rmSync(temporary, { recursive: true, force: true }); + if (isCompleteRuntime(directory)) return entry; + throw new TerminalWorkerRuntimeError( + `Cannot copy the terminal worker into ${directory}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + return entry; +} + +export interface PruneTerminalWorkerRuntimesResult { + removed: string[]; + /** Copies that could not be deleted (a worker that just exited can still hold files on Windows). */ + failed: Array<{ directory: string; error: string }>; +} + +/** + * Deletes every copy no worker record names. With an unreadable record it deletes nothing: that + * record could name a copy a live worker runs from. + */ +export async function pruneTerminalWorkerRuntimes( + paseoHome: string, +): Promise { + const result: PruneTerminalWorkerRuntimesResult = { removed: [], failed: [] }; + const root = terminalWorkerDirectory(paseoHome); + const files = await listTerminalWorkerRecords(paseoHome); + const inUse = new Set(); + for (const file of files) { + if ("error" in file) return result; + if (file.record.runtime) inUse.add(file.record.runtime); + } + let names: string[]; + try { + names = await readdir(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return result; + throw error; + } + for (const name of names) { + if (inUse.has(name)) continue; + const directory = path.join(root, name); + if ( + !name.startsWith(TEMP_PREFIX) && + !(await access(path.join(directory, RUNTIME_MARKER)).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return false; + throw error; + }, + )) + ) + continue; + try { + await rm(directory, { recursive: true, force: true }); + result.removed.push(directory); + } catch (error) { + result.failed.push({ + directory, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return result; +} diff --git a/packages/server/src/terminal/terminal-worker-socket-path.test.ts b/packages/server/src/terminal/terminal-worker-socket-path.test.ts new file mode 100644 index 0000000000..409cf60c81 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-socket-path.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { isPlatform } from "../test-utils/platform.js"; +import { + resolveTerminalWorkerSocketPath, + TerminalWorkerSocketPathError, +} from "./terminal-worker-socket-path.js"; + +const temporaryDirs: string[] = []; + +function makeDir(prefix: string, root = tmpdir()): string { + const dir = mkdtempSync(path.join(root, prefix)); + temporaryDirs.push(dir); + return dir; +} + +// A home path long enough that its socket cannot fit sun_path, with multi-byte segments so the +// budget is measured in bytes, not characters. +function longHome(root: string): string { + const home = path.join(root, "worktree-ñandú-".repeat(5), "paseo-home"); + mkdirSync(home, { recursive: true }); + return home; +} + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { + it("uses the home's own socket directory when the path fits", () => { + // macOS $TMPDIR alone is ~50 bytes; a short root keeps the home's own path in budget. + const home = makeDir("tw-h-", "/tmp"); + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 4242, + platform: "darwin", + }); + expect(socketPath.startsWith(path.join(home, "terminal-worker", "sock"))).toBe(true); + }); + + it("falls to /tmp/paseo- when the home and TMPDIR are too long, within the byte budget", () => { + const root = makeDir("tw-sock-long-"); + const home = longHome(root); + const tmpDir = path.join(root, "tmp-ñandú-".repeat(6)); + mkdirSync(tmpDir, { recursive: true }); + const uid = process.getuid!(); + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 99999, + platform: "darwin", + tmpDir, + }); + expect(path.dirname(socketPath)).toBe(`/tmp/paseo-${uid}`); + expect(Buffer.byteLength(socketPath, "utf8")).toBeLessThanOrEqual(103); + }); + + it("rejects a shared-root directory that is a symlink, has another mode, or is not a directory", () => { + const root = makeDir("tw-sock-reject-"); + const home = longHome(root); + const uid = process.getuid!(); + const tmpDir = makeDir("tw-"); + const target = makeDir("tw-target-"); + chmodSync(target, 0o700); + symlinkSync(target, path.join(tmpDir, `paseo-${uid}`)); + // A budget check alone would accept the long-home fallback through the symlink; it must + // also be refused, and /tmp/paseo- takes over. + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir, + }); + expect(path.dirname(socketPath)).toBe(`/tmp/paseo-${uid}`); + + const wideTmp = makeDir("tw-"); + mkdirSync(path.join(wideTmp, `paseo-${uid}`), { mode: 0o755 }); + chmodSync(path.join(wideTmp, `paseo-${uid}`), 0o755); + expect( + path.dirname( + resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir: wideTmp, + }), + ), + ).toBe(`/tmp/paseo-${uid}`); + }); + + it("names every rejected candidate with its byte length when none fits", () => { + const root = makeDir("tw-sock-none-"); + const home = longHome(root); + const tmpDir = path.join(root, "tmp-ñandú-".repeat(6)); + mkdirSync(tmpDir, { recursive: true }); + let caught: unknown; + try { + resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir, + // Another uid's /tmp/paseo- directory does not exist or is not ours; with a uid + // nobody owns, the /tmp candidate's directory is created by us but owned by our real + // uid, so it is rejected for its owner. + uid: 2_000_000_000, + }); + } catch (error) { + caught = error; + } + rmSync("/tmp/paseo-2000000000", { recursive: true, force: true }); + expect(caught).toBeInstanceOf(TerminalWorkerSocketPathError); + const rejected = (caught as TerminalWorkerSocketPathError).rejected; + expect(rejected).toHaveLength(3); + expect(rejected[0].reason).toMatch(/longer than 103 bytes/); + expect(rejected[0].byteLength).toBe(Buffer.byteLength(rejected[0].path, "utf8")); + expect(rejected[2].reason).toMatch(/owned by uid/); + }); + + it("accepts a real listen on the chosen path", async () => { + const home = makeDir("tw-l-", "/tmp"); + const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, workerPid: 5 }); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + await new Promise((resolve) => server.close(() => resolve())); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-socket-path.ts b/packages/server/src/terminal/terminal-worker-socket-path.ts new file mode 100644 index 0000000000..99fce0e64c --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-socket-path.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { lstatSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { terminalWorkerDirectory } from "./terminal-worker-record.js"; + +// `sun_path` holds 104 bytes on macOS and 108 on Linux, NUL included. Dev homes under +// worktrees and long $TMPDIR values exceed that, so the socket goes to the first owner-only +// directory whose full path fits, measured in UTF-8 bytes. +const SOCKET_PATH_BYTE_BUDGET: Partial> = { + darwin: 103, + linux: 107, +}; +const DEFAULT_SOCKET_PATH_BYTE_BUDGET = 103; +const OWNER_ONLY_MODE = 0o700; + +export interface TerminalWorkerSocketPathInput { + paseoHome: string; + workerPid: number; + platform?: NodeJS.Platform; + tmpDir?: string; + uid?: number; +} + +export interface RejectedSocketCandidate { + path: string; + byteLength: number; + reason: string; +} + +export class TerminalWorkerSocketPathError extends Error { + readonly rejected: RejectedSocketCandidate[]; + + constructor(rejected: RejectedSocketCandidate[], budget: number) { + super( + [ + `No terminal worker socket path fits ${budget} bytes in an owner-only directory:`, + ...rejected.map( + (candidate) => ` ${candidate.path} (${candidate.byteLength} bytes): ${candidate.reason}`, + ), + ].join("\n"), + ); + this.name = "TerminalWorkerSocketPathError"; + this.rejected = rejected; + } +} + +/** First 16 hex chars of sha256(home) plus the worker pid: unique per home and worker. */ +export function terminalWorkerSocketId(paseoHome: string, workerPid: number): string { + const digest = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); + return `${digest.slice(0, 16)}-${workerPid}`; +} + +/** + * Picks (and creates) the socket path a worker listens on. Windows uses a named pipe; POSIX + * tries $PASEO_HOME/terminal-worker/sock, then /paseo-, then /tmp/paseo-. + * A shared root is never chmodded: an existing directory there is used only when it is a real + * directory owned by this user with mode 0700. + */ +export function resolveTerminalWorkerSocketPath(input: TerminalWorkerSocketPathInput): string { + const platform = input.platform ?? process.platform; + const id = terminalWorkerSocketId(input.paseoHome, input.workerPid); + if (platform === "win32") { + return `\\\\.\\pipe\\paseo-tw-${id}`; + } + + const uid = input.uid ?? process.getuid?.() ?? 0; + const budget = SOCKET_PATH_BYTE_BUDGET[platform] ?? DEFAULT_SOCKET_PATH_BYTE_BUDGET; + const fileName = `tw-${id}.sock`; + const homeSocketDirectory = path.join(terminalWorkerDirectory(input.paseoHome), "sock"); + const candidates: Array<{ directory: string; prepare: () => string | null }> = [ + { + directory: homeSocketDirectory, + prepare: () => { + ensurePrivateDirectory(homeSocketDirectory); + return verifyOwnerOnlyDirectory(homeSocketDirectory, uid); + }, + }, + ...[input.tmpDir ?? tmpdir(), "/tmp"].map((root) => { + const directory = path.join(root, `paseo-${uid}`); + return { + directory, + prepare: () => prepareSharedRootDirectory(directory, uid), + }; + }), + ]; + + const rejected: RejectedSocketCandidate[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const socketPath = path.join(candidate.directory, fileName); + if (seen.has(socketPath)) continue; + seen.add(socketPath); + const byteLength = Buffer.byteLength(socketPath, "utf8"); + if (byteLength > budget) { + rejected.push({ path: socketPath, byteLength, reason: `longer than ${budget} bytes` }); + continue; + } + let problem: string | null; + try { + problem = candidate.prepare(); + } catch (error) { + problem = error instanceof Error ? error.message : String(error); + } + if (problem) { + rejected.push({ path: socketPath, byteLength, reason: problem }); + continue; + } + return socketPath; + } + throw new TerminalWorkerSocketPathError(rejected, budget); +} + +function prepareSharedRootDirectory(directory: string, uid: number): string | null { + try { + mkdirSync(directory, { mode: OWNER_ONLY_MODE }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + return verifyOwnerOnlyDirectory(directory, uid); +} + +/** Returns why the directory cannot hold the socket, or null when it is owner-only. */ +function verifyOwnerOnlyDirectory(directory: string, uid: number): string | null { + const stats = lstatSync(directory); + if (stats.isSymbolicLink()) return "is a symlink"; + if (!stats.isDirectory()) return "is not a directory"; + if (stats.uid !== uid) return `is owned by uid ${stats.uid}, not ${uid}`; + const mode = stats.mode & 0o777; + if (mode !== OWNER_ONLY_MODE) return `has mode ${mode.toString(8)}, not 700`; + return null; +} diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 24f243b6ea..b8cb7ee8cd 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -15,6 +15,11 @@ import { basename, delimiter, dirname, extname, join, resolve as resolvePath } f import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; +import { + resolveExternalProcessPath, + resolvePaseoCliBinDir, + resolvePaseoCliExecutablePath, +} from "./paseo-cli-path.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; import { findExecutable, @@ -27,7 +32,6 @@ import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol const { Terminal } = xterm; const require = createRequire(import.meta.url); -const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo"; let nodePtySpawnHelperChecked = false; const TERMINAL_TITLE_DEBOUNCE_MS = 150; const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12; @@ -123,6 +127,11 @@ export interface TerminalSession { killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; } +/** A session that owns its PTY in this process, so it knows the program's pid. */ +export interface LocalTerminalSession extends TerminalSession { + readonly pid: number; +} + function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null { // OSC 633 is terminal control traffic, but a foreground command can still // print arbitrary control bytes. Keep this boundary to the exact VS Code @@ -153,6 +162,11 @@ export interface CreateTerminalOptions { title?: string; command?: string; args?: string[]; + // Environment the terminal starts from instead of this process's own. A detached terminal + // worker outlives the daemon that launched it, so each create carries the controlling + // daemon's environment and its resolved Paseo CLI path. + baseEnv?: Record; + paseoCliPath?: string | null; } function toTerminalActivity(snapshot: { @@ -181,6 +195,7 @@ function isTerminalActivityInterruptInput(data: string): boolean { interface BuildTerminalEnvironmentInput { shell: string; env: Record; + baseEnv?: Record; zshShellIntegrationDir?: string; paseoCliBinDir?: string | null; paseoHookCliPath?: string | null; @@ -410,84 +425,12 @@ export async function resolveTerminalSpawnCommand( return { command: resolved, args }; } +export { resolvePaseoCliBinDir, resolvePaseoCliExecutablePath }; + export function resolveZshShellIntegrationDir(): string { return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url)); } -function resolveExternalProcessPath(filePath: string): string { - return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked"); -} - -export function resolvePaseoCliBinDir(): string | null { - const cliExecutable = resolvePaseoCliExecutablePath(); - return cliExecutable ? dirname(cliExecutable) : null; -} - -export function resolvePaseoCliExecutablePath(): string | null { - const configuredCli = process.env.PASEO_CLI?.trim(); - if (configuredCli) { - return resolvePath(configuredCli); - } - - const cliEntrypoint = resolvePaseoCliBinEntrypoint(); - if (!cliEntrypoint) { - return null; - } - - const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); - const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint)); - if (npmBinDir) { - const shim = resolvePaseoCliShim(npmBinDir); - if (shim) { - return shim; - } - } - - return externalCliEntrypoint; -} - -function resolvePaseoCliBinEntrypoint(): string | null { - try { - return require.resolve(PASEO_CLI_BIN_ENTRY); - } catch { - return null; - } -} - -function findNpmBinDir(startPath: string): string | null { - let current = startPath; - while (true) { - const candidate = join(current, "node_modules", ".bin"); - if (hasPaseoCliShim(candidate)) { - return candidate; - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -function hasPaseoCliShim(binDir: string): boolean { - return resolvePaseoCliShim(binDir) !== null; -} - -function resolvePaseoCliShim(binDir: string): string | null { - for (const name of paseoCliShimNames()) { - const candidate = join(binDir, name); - if (existsSync(candidate)) { - return candidate; - } - } - return null; -} - -function paseoCliShimNames(): string[] { - return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"]; -} - function resolveZshShellIntegrationRuntimeDir(): string { let username = "unknown"; try { @@ -517,10 +460,14 @@ function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegra export function buildTerminalEnvironment( input: BuildTerminalEnvironmentInput, ): Record { - const baseEnv: Record = createExternalProcessEnv(process.env, input.env, { - TERM: "xterm-256color", - TERM_PROGRAM: "kitty", - }); + const baseEnv: Record = createExternalProcessEnv( + input.baseEnv ?? process.env, + input.env, + { + TERM: "xterm-256color", + TERM_PROGRAM: "kitty", + }, + ); const envWithAgentHooks = prependPaseoCliToPath( baseEnv, input.paseoCliBinDir === undefined ? resolvePaseoCliBinDir() : input.paseoCliBinDir, @@ -556,6 +503,23 @@ function injectPaseoHookCli( }; } +// The environment and CLI path a terminal starts from: the caller's when it sends them (the +// daemon controlling a detached worker does), this process's otherwise. +function resolveTerminalEnvironmentSource( + input: Pick, +): Pick { + const source: Pick< + BuildTerminalEnvironmentInput, + "baseEnv" | "paseoCliBinDir" | "paseoHookCliPath" + > = {}; + if (input.baseEnv) source.baseEnv = input.baseEnv; + if (input.paseoCliPath !== undefined) { + source.paseoCliBinDir = input.paseoCliPath ? dirname(input.paseoCliPath) : null; + source.paseoHookCliPath = input.paseoCliPath; + } + return source; +} + function prependPaseoCliToPath( env: Record, cliBinDir: string | null, @@ -967,7 +931,9 @@ export async function assertTerminalCommandCanStart( } } -export async function createTerminal(options: CreateTerminalOptions): Promise { +export async function createTerminal( + options: CreateTerminalOptions, +): Promise { const { cwd, workspaceId, @@ -980,8 +946,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); @@ -1033,6 +1001,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): boolean { @@ -100,14 +131,7 @@ class FakeTerminalWorker extends EventEmitter { disconnect(): void { this.connected = false; - this.emit("exit", 0, null); - } - - kill(): boolean { - this.killed = true; - this.connected = false; - this.emit("exit", 0, null); - return true; + this.emit("close"); } emitWorkerMessage(message: TerminalWorkerToParentMessage): void { @@ -115,8 +139,27 @@ class FakeTerminalWorker extends EventEmitter { } } -let manager: TerminalManager | null = null; +let manager: DaemonTerminalManager | null = null; const temporaryDirs: string[] = []; + +// A real detached worker in a throwaway home; afterEach closes its terminals and the worker. +async function startRealWorkerManager( + options: { getTerminalActivityUrl?: () => string | null; paseoHome?: string } = {}, +): Promise { + const { paseoHome = createTemporaryHome(), ...rest } = options; + return startWorkerTerminalManager({ + paseoHome, + paseoVersion: "0.0.0-test", + logger: pino({ level: "silent" }), + ...rest, + }); +} + +function createTemporaryHome(): string { + const paseoHome = mkdtempSync(join(tmpdir(), "worker-terminal-manager-home-")); + temporaryDirs.push(paseoHome); + return paseoHome; +} const terminalSessions: TerminalSession[] = []; function trackTerminal(session: TerminalSession): TerminalSession { @@ -150,7 +193,7 @@ afterEach(async () => { .catch(() => {}), ), ); - manager?.killAll(); + await manager?.closeTerminals(); manager = null; while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); @@ -160,10 +203,177 @@ afterEach(async () => { } }); +it("retires a killed worker's sessions and shares one replacement across concurrent creates", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + manager.registerCwdEnv({ cwd: paseoHome, env: { QA_RECOVERY_ENV: "retained" } }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-recovery", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + process.env.QA_RECOVERY_ENV + ':' + chunk)); setInterval(() => {}, 1000)", + ), + }); + const records = await listTerminalWorkerRecords(paseoHome); + const file = records.find((entry) => !("error" in entry)); + if (!file || "error" in file) throw new Error("Missing worker record"); + const changes: string[][] = []; + manager.subscribeTerminalsChanged((event) => + changes.push(event.terminals.map((item) => item.id)), + ); + const exits: unknown[] = []; + terminal.onExit((info) => exits.push(info)); + process.kill(file.record.pid, "SIGKILL"); + await waitForCondition(() => manager!.getTerminal(terminal.id) === undefined, 10000); + expect(changes).toContainEqual([]); + expect(exits).toHaveLength(1); + const sessions = await Promise.all( + Array.from({ length: 4 }, () => + manager!.createTerminal({ + workspaceId: "ws-recovery", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + process.env.QA_RECOVERY_ENV + ':' + chunk)); setInterval(() => {}, 1000)", + ), + }), + ), + ); + for (const session of sessions) { + const output: string[] = []; + session.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + session.send({ type: "input", data: "recovered\r" }); + await waitForCondition(() => output.join("").includes("echo:retained:recovered"), 10000); + } + expect(await manager.getTerminals(paseoHome)).toHaveLength(4); + const remaining = (await listTerminalWorkerRecords(paseoHome)).filter( + (entry) => !("error" in entry), + ); + expect(remaining).toHaveLength(1); +}, 30000); + +it("reconnects a live worker without replacing terminal sessions or processes", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-reconnect", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + chunk)); setInterval(() => {}, 1000)", + ), + }); + const [before] = await workerRecords(paseoHome); + const pids = await recordedTerminalPids(paseoHome); + let replayRequests = 0; + terminal.subscribe((message) => { + if (message.type === "snapshotReady") replayRequests += 1; + }); + // A second authenticated controller replaces the daemon's socket. The process stays alive. + const competitor = await connectTerminalWorker({ + socketPath: before!.socketPath, + secret: before!.secret, + }); + await waitForCondition(() => !competitor.connected, 10000); + await waitForCondition(async () => { + try { + return (await manager!.getTerminalState(terminal.id)) !== null; + } catch { + return false; + } + }, 10000); + await waitForCondition(() => replayRequests > 0, 1000); + expect(manager.getTerminal(terminal.id)).toBe(terminal); + expect(await recordedTerminalPids(paseoHome)).toEqual(pids); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([before!.pid]); + const output: string[] = []; + terminal.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + terminal.send({ type: "input", data: "reconnected\r" }); + await waitForCondition(() => output.join("").includes("echo:reconnected"), 10000); +}, 30000); + +it.each(["detach", "closeTerminals"] as const)( + "does not replace a failed worker during %s", + async (operation) => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + await manager.createTerminal({ + workspaceId: "ws-stop-recovery", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const [record] = await workerRecords(paseoHome); + process.kill(record!.pid, "SIGKILL"); + await manager[operation](); + await expect( + manager.createTerminal({ workspaceId: "ws-stop-recovery", cwd: paseoHome }), + ).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); + await manager.closeTerminals(); + expect(await workerRecords(paseoHome)).toEqual([]); + }, + 30000, +); + +it.each(["detach", "closeTerminals"] as const)( + "does not leak a replacement stopped during launch by %s", + async (operation) => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-launch-race", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const [record] = await workerRecords(paseoHome); + process.kill(record!.pid, "SIGKILL"); + await waitForCondition(() => !manager!.getTerminal(terminal.id), 10000); + const creating = manager.createTerminal({ workspaceId: "ws-launch-race", cwd: paseoHome }); + const rejected = expect(creating).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); + await manager[operation](); + await rejected; + await waitForCondition(async () => (await workerRecords(paseoHome)).length === 0, 10000); + expect(await manager.getTerminals(paseoHome)).toEqual([]); + }, + 30000, +); + +it.each(["current", "draining"] as const)( + "keeps the peer usable when the %s worker dies", + async (target) => { + const paseoHome = createTemporaryHome(); + const old = await leaveWorkerWithTerminal(paseoHome, 0); + manager = await startRealWorkerManager({ paseoHome }); + const added = await manager.createTerminal({ + workspaceId: "ws-peer", + cwd: old.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const current = (await workerRecords(paseoHome)).find( + (record) => record.pid !== old.workerPid, + )!; + const deadId = target === "current" ? added.id : old.terminalId; + const healthyId = target === "current" ? old.terminalId : added.id; + const healthy = manager.getTerminal(healthyId); + process.kill(target === "current" ? current.pid : old.workerPid, "SIGKILL"); + await waitForCondition(() => !manager!.getTerminal(deadId), 10000); + expect(manager.getTerminal(healthyId)).toBe(healthy); + expect(await manager.getTerminalState(healthyId)).not.toBeNull(); + await manager.createTerminal({ + workspaceId: "ws-peer", + cwd: old.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + expect((await workerRecords(paseoHome)).length).toBeLessThanOrEqual(2); + }, + 30000, +); + it("creates a terminal through the worker and streams output", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-output-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -208,7 +418,7 @@ it("delivers rapid small writes complete and in order through worker coalescing" const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-coalesce-")); temporaryDirs.push(cwd); const burstGatePath = join(cwd, "burst-ready"); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); // The file gate starts the burst after this test subscribes without depending // on platform-specific PTY input echo/canonical-mode behavior. const session = trackTerminal( @@ -271,7 +481,7 @@ it("delivers rapid small writes complete and in order through worker coalescing" it("pulls fresh terminal state from the worker authority", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-state-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -302,7 +512,7 @@ it.skipIf(isPlatform("win32"))( async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-preamble-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); // \x1b[>1u pushes kitty keyboard flag 1, which the worker's input-mode // tracker records and reflects in its replay preamble (\x1b[=1;1u). const session = trackTerminal( @@ -328,7 +538,7 @@ it.skipIf(isPlatform("win32"))( it("refreshes cached terminal title after worker title changes", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-title-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -348,7 +558,7 @@ it("refreshes cached terminal title after worker title changes", async () => { it("refreshes cached terminal size after worker resize", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-resize-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "resize", rows: 10, cols: 40 }); @@ -363,7 +573,7 @@ it("refreshes cached terminal size after worker resize", async () => { it("captures terminal output from the worker authority", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-capture-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "input", data: "echo hello world\r" }); @@ -382,7 +592,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -416,7 +626,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy }); it("keeps registered cwd env inheritance behind the worker manager interface", async () => { - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-env-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "env.txt"); @@ -449,7 +659,7 @@ it("injects parent-minted terminal activity env through the worker", async () => temporaryDirs.push(cwd); const envPath = join(cwd, "activity-env.json"); const activityUrl = "http://127.0.0.1:12345/api/terminal-activity"; - manager = createWorkerTerminalManager({ + manager = await startRealWorkerManager({ getTerminalActivityUrl: () => activityUrl, }); @@ -496,8 +706,177 @@ it("injects parent-minted terminal activity env through the worker", async () => expect(env.path?.split(delimiter)[0]).toBe(paseoCliBinDir); }); +it("adopts a detached worker's terminals with their activity tokens and keeps them usable", async () => { + const paseoHome = createTemporaryHome(); + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-adopt-")); + temporaryDirs.push(cwd); + const envPath = join(cwd, "adopt-env.json"); + const first = await startRealWorkerManager({ paseoHome }); + const created = await first.createTerminal({ + workspaceId: "ws-adopt", + cwd, + ...nodeTerminalCommand(` + require("node:fs").writeFileSync( + ${JSON.stringify(envPath)}, + JSON.stringify({ token: process.env.PASEO_ACTIVITY_TOKEN }), + ); + process.stdin.on("data", (chunk) => { + process.stdout.write("adopted-output:" + chunk.toString()); + }); + setInterval(() => {}, 1000); + `), + }); + await waitForCondition(() => existsSync(envPath), 10000); + const { token } = JSON.parse(readFileSync(envPath, "utf8")) as { token: string }; + + await first.detach(); + manager = await startRealWorkerManager({ paseoHome }); + + const adopted = manager.getTerminal(created.id); + expect(adopted?.workspaceId).toBe("ws-adopt"); + expect(await manager.getTerminals(cwd)).toHaveLength(1); + expect(manager.validateTerminalActivityToken(created.id, token)).toBe("valid"); + const messages: string[] = []; + adopted?.subscribe((message) => { + if (message.type === "output") messages.push(message.data); + }); + adopted?.send({ type: "input", data: "after-adopt\r" }); + await waitForCondition(() => messages.join("").includes("adopted-output:after-adopt"), 10000); +}); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// The terminals the worker itself recorded, read from its own record file. +async function recordedTerminalPids(paseoHome: string): Promise> { + const pids = new Map(); + const directory = terminalWorkerDirectory(paseoHome); + for (const name of readdirSync(directory).filter((entry) => /^\d+\.json$/.test(entry))) { + for (const entry of (await readTerminalWorkerRecord(join(directory, name))).terminals) { + pids.set(entry.id, entry.pid); + } + } + return pids; +} + +it("never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record", async () => { + const paseoHome = createTemporaryHome(); + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-")); + temporaryDirs.push(cwd); + const first = await startRealWorkerManager({ paseoHome }); + manager = first; + const ending = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const remaining = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const pids = await recordedTerminalPids(paseoHome); + const endingPid = pids.get(ending.id)!; + const remainingPid = pids.get(remaining.id)!; + + // A kill cleanup whose process table read fails signals nothing and leaves the worker live. + const inspector = createTerminalWorkerProcessInspector(); + await expect( + reapTerminalWorkers({ + paseoHome, + inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) }, + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + await first.detach(); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + + // One terminal ends on its own: the live worker rewrites its record from memory. + process.kill(endingPid, "SIGKILL"); + await waitForCondition( + async () => !(await recordedTerminalPids(paseoHome)).has(ending.id), + 10000, + ); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + + manager = await startRealWorkerManager({ paseoHome }); + + expect(manager.getTerminal(remaining.id)).toBeUndefined(); + expect(await manager.getTerminals(cwd)).toEqual([]); + await waitForCondition(() => !isProcessAlive(remainingPid), 10000); + expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); +}); + +// The worker's own `ps`, delayed: a program that exits while the worker reads its birth time +// lands its exit in the middle of creation. Only the worker launched while this PATH is set sees +// the shim; the PATH is restored as soon as it is up. +async function startWorkerWithSlowProcessTable( + delaySeconds: number, +): Promise { + const realPs = (process.env.PATH ?? "") + .split(delimiter) + .map((dir) => join(dir, "ps")) + .find((candidate) => existsSync(candidate)); + if (!realPs) throw new Error("ps not found on PATH"); + const shimDir = createTemporaryHome(); + writeFileSync(join(shimDir, "ps"), `#!/bin/sh\nsleep ${delaySeconds}\nexec ${realPs} "$@"\n`, { + mode: 0o755, + }); + const originalPath = process.env.PATH; + process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`; + try { + return await startRealWorkerManager(); + } finally { + process.env.PATH = originalPath; + } +} + +async function expectCompletedShortCommand( + terminalManager: DaemonTerminalManager, + script: string, +): Promise { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-short-")); + temporaryDirs.push(cwd); + + const session = await terminalManager.createTerminal({ + workspaceId: "ws-test", + cwd, + command: "/bin/sh", + args: ["-c", script], + }); + + await waitForCondition(() => session.getExitInfo() !== null, 10000); + expect(session.getExitInfo()?.exitCode).toBe(0); + expect(terminalManager.getTerminal(session.id)).toBeUndefined(); + expect(await terminalManager.getTerminals(cwd)).toEqual([]); + expect(await terminalManager.getTerminalState(session.id)).toBeNull(); +} + +it.skipIf(isPlatform("win32"))( + "creates a short command that finishes before creation returns, with its exit", + async () => { + manager = await startRealWorkerManager(); + for (let attempt = 0; attempt < 3; attempt += 1) { + await expectCompletedShortCommand(manager, "echo short; exit 0"); + } + }, +); + +it.skipIf(isPlatform("win32"))( + "publishes the creation before an exit that arrives while the program is being recorded", + async () => { + manager = await startWorkerWithSlowProcessTable(0.3); + await expectCompletedShortCommand(manager, "sleep 0.1; echo short; exit 0"); + }, +); + it("starts the default shell through the worker and accepts quoted commands", async () => { - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "shell quoted marker.txt"); @@ -518,7 +897,7 @@ it("lists subdirectory terminals when querying the workspace root", async () => const subdirCwd = join(rootCwd, "apps", "mobile"); mkdirSync(subdirCwd, { recursive: true }); temporaryDirs.push(rootCwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const created = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -536,7 +915,7 @@ it("lists terminals locally without waiting on the worker", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -577,7 +956,7 @@ it("includes only stamped terminals in workspace-scoped local reads", async () = const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -629,7 +1008,7 @@ it("rejects non-absolute cwd in getTerminals", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); await expect(manager.getTerminals("relative/path")).rejects.toThrow("cwd must be absolute path"); @@ -639,7 +1018,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -702,7 +1081,7 @@ it("sets terminal activity through a worker request", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ type: "terminalCreated", @@ -735,7 +1114,7 @@ it("clears terminal attention through a worker request", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ type: "terminalCreated", @@ -766,7 +1145,7 @@ it("clears terminal attention through a worker request", async () => { it("clears finished attention on a real terminal", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-attention-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -802,7 +1181,7 @@ it("clears finished attention on a real terminal", async () => { it("removes worker terminals after killAndWait", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -827,7 +1206,7 @@ it("produces one terminals-changed snapshot per title change", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -864,7 +1243,7 @@ it("produces one terminals-changed snapshot and one contribution event per activ const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -914,7 +1293,7 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const workingActivity = { state: "working" as const, changedAt: 1000 }; @@ -973,7 +1352,7 @@ it("does not resurrect a terminal that exited before its create response was han const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; @@ -1026,7 +1405,7 @@ it("replays the exit to a listener attached after a create that lost the race", const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const created = manager.createTerminal({ @@ -1074,7 +1453,7 @@ it("rejects createTerminal and registers nothing when the worker cannot start th const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; @@ -1104,3 +1483,256 @@ it("rejects createTerminal and registers nothing when the worker cannot start th expect(await manager.getTerminals("/workspace")).toEqual([]); expect(snapshots).toEqual([]); }); + +// The live worker records in a home, by worker pid. +async function workerRecords(paseoHome: string): Promise { + return (await listTerminalWorkerRecords(paseoHome)).flatMap((file) => + "record" in file && !file.record.closing ? [file.record] : [], + ); +} + +// A worker launched while the override is set reports that protocol version; the variable is +// restored as soon as it is up, so later workers report the build's own. +async function startManagerWithWorkerProtocol( + paseoHome: string, + protocolVersion: number, +): Promise { + const previous = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = String(protocolVersion); + try { + return await startRealWorkerManager({ paseoHome }); + } finally { + if (previous === undefined) delete process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + else process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = previous; + } +} + +// A terminal running an idle program in a worker that reports `protocolVersion`, left running +// after its daemon lets go (stop --keep-terminals). +async function leaveWorkerWithTerminal( + paseoHome: string, + protocolVersion: number, +): Promise<{ terminalId: string; programPid: number; workerPid: number; cwd: string }> { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-protocol-")); + temporaryDirs.push(cwd); + const previous = await startManagerWithWorkerProtocol(paseoHome, protocolVersion); + const session = await previous.createTerminal({ + workspaceId: "ws-protocol", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const [record] = await workerRecords(paseoHome); + await previous.detach(); + return { + terminalId: session.id, + programPid: (await recordedTerminalPids(paseoHome)).get(session.id)!, + workerPid: record!.pid, + cwd, + }; +} + +it("drains a worker one protocol older: it keeps its terminal, new ones open in a second worker, and it exits after its last", async () => { + const paseoHome = createTemporaryHome(); + const old = await leaveWorkerWithTerminal(paseoHome, 0); + + manager = await startRealWorkerManager({ paseoHome }); + + expect(manager.getTerminal(old.terminalId)).toBeDefined(); + const workers = await workerRecords(paseoHome); + expect(workers).toHaveLength(2); + const current = workers.find((record) => record.pid !== old.workerPid)!; + expect(current.protocolVersion).toBe(1); + const added = await manager.createTerminal({ + workspaceId: "ws-protocol", + cwd: old.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + trackTerminal(added); + const currentTerminals = ( + await readTerminalWorkerRecord(join(terminalWorkerDirectory(paseoHome), `${current.pid}.json`)) + ).terminals.map((entry) => entry.id); + expect(currentTerminals).toEqual([added.id]); + expect((await manager.getTerminals(old.cwd)).map((session) => session.id).sort()).toEqual( + [old.terminalId, added.id].sort(), + ); + + await manager.killTerminalAndWait(old.terminalId); + + await waitForCondition(() => !isProcessAlive(old.workerPid), 10000); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([current.pid]); + expect(manager.getTerminal(added.id)).toBeDefined(); +}); + +it("shuts down a worker two protocol versions old, closing its terminals", async () => { + const paseoHome = createTemporaryHome(); + const old = await leaveWorkerWithTerminal(paseoHome, -1); + + manager = await startRealWorkerManager({ paseoHome }); + + await waitForCondition( + () => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid), + 10000, + ); + expect(manager.getTerminal(old.terminalId)).toBeUndefined(); + const workers = await workerRecords(paseoHome); + expect(workers).toHaveLength(1); + expect(workers[0]!.protocolVersion).toBe(1); +}); + +it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { + const paseoHome = createTemporaryHome(); + const newer = await leaveWorkerWithTerminal(paseoHome, 2); + + manager = await startRealWorkerManager({ paseoHome }); + + const unavailable = manager.getUnavailableError?.(); + expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); + expect(unavailable?.message).toContain("worker protocol 2"); + await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( + TerminalWorkerIncompatibleError, + ); + await expect( + manager.createTerminal({ workspaceId: "ws-protocol", cwd: newer.cwd }), + ).rejects.toBeInstanceOf(TerminalWorkerIncompatibleError); + expect(isProcessAlive(newer.programPid)).toBe(true); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([newer.workerPid]); + + await manager.closeTerminals(); + + expect(isProcessAlive(newer.workerPid)).toBe(false); + expect(isProcessAlive(newer.programPid)).toBe(false); + expect(await workerRecords(paseoHome)).toEqual([]); +}); + +// A worker launched on its own, as two daemons that each started one would leave it: running an +// idle program in a terminal, with no daemon attached. +async function launchDetachedWorkerWithTerminal( + paseoHome: string, + protocolVersion?: number, +): Promise<{ terminalId: string; workerPid: number; cwd: string }> { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-detached-")); + temporaryDirs.push(cwd); + const previous = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + if (protocolVersion !== undefined) { + process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = String(protocolVersion); + } + let launched: Awaited>; + try { + launched = await launchTerminalWorker({ paseoHome, paseoVersion: "0.0.0-test" }); + } finally { + if (previous === undefined) delete process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + else process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = previous; + } + const worker = createWorkerTerminalManager({ + worker: await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }), + }); + const session = await worker.createTerminal({ + workspaceId: "ws-detached", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + await worker.detach(); + return { terminalId: session.id, workerPid: launched.record.pid, cwd }; +} + +// Worker pids alive in a home, from every record, closing ones included. +async function liveWorkerPids(paseoHome: string): Promise { + return (await listTerminalWorkerRecords(paseoHome)) + .flatMap((file) => ("record" in file ? [file.record.pid] : [])) + .filter(isProcessAlive) + .sort(); +} + +it.skipIf(isPlatform("win32"))( + "counts live workers whose cleanup could not be confirmed and never starts a third", + async () => { + const paseoHome = createTemporaryHome(); + const first = await launchDetachedWorkerWithTerminal(paseoHome); + const second = await launchDetachedWorkerWithTerminal(paseoHome); + // A kill cleanup that cannot read the process table signals nothing: both workers keep + // running with their records marked closing. + const inspector = createTerminalWorkerProcessInspector(); + await expect( + reapTerminalWorkers({ + paseoHome, + gracefulMs: 0, + inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) }, + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).map((entry) => entry.role)).toContain( + "worker", + ); + + // The daemon's own retry fails the same way: only the full process table is unreadable. + const realPs = (process.env.PATH ?? "") + .split(delimiter) + .map((dir) => join(dir, "ps")) + .find((candidate) => existsSync(candidate)); + if (!realPs) throw new Error("ps not found on PATH"); + const shimDir = createTemporaryHome(); + writeFileSync( + join(shimDir, "ps"), + `#!/bin/sh\nif [ "$1" = "-A" ]; then echo "process table unavailable" >&2; exit 1; fi\nexec ${realPs} "$@"\n`, + { mode: 0o755 }, + ); + const originalPath = process.env.PATH; + process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`; + try { + manager = await startRealWorkerManager({ paseoHome }); + } finally { + process.env.PATH = originalPath; + } + + expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); + await expect( + manager.createTerminal({ workspaceId: "ws-detached", cwd: first.cwd }), + ).rejects.toThrow("2 terminal workers are already running"); + expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); + }, +); + +it("opens new terminals in a new worker once one of two draining workers exits", async () => { + const paseoHome = createTemporaryHome(); + const first = await launchDetachedWorkerWithTerminal(paseoHome, 0); + const second = await launchDetachedWorkerWithTerminal(paseoHome, 0); + + manager = await startRealWorkerManager({ paseoHome }); + + expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); + await expect( + manager.createTerminal({ workspaceId: "ws-detached", cwd: first.cwd }), + ).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); + + await manager.killTerminalAndWait(first.terminalId); + await waitForCondition(() => !isProcessAlive(first.workerPid), 10000); + + // Concurrent creates start one worker between them. + const created = await Promise.all( + [0, 1].map(() => + manager!.createTerminal({ + workspaceId: "ws-detached", + cwd: first.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }), + ), + ); + created.forEach(trackTerminal); + const workers = await workerRecords(paseoHome); + expect(workers.map((record) => record.pid).sort()).toEqual(await liveWorkerPids(paseoHome)); + expect(workers).toHaveLength(2); + const started = workers.find((record) => record.pid !== second.workerPid)!; + expect(started.protocolVersion).toBe(1); + expect( + ( + await readTerminalWorkerRecord( + join(terminalWorkerDirectory(paseoHome), `${started.pid}.json`), + ) + ).terminals + .map((entry) => entry.id) + .sort(), + ).toEqual(created.map((session) => session.id).sort()); + expect(manager.getTerminal(second.terminalId)).toBeDefined(); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0a1adb2c44..c9a6d7186e 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -1,7 +1,10 @@ -import { fileURLToPath } from "node:url"; -import { fork } from "node:child_process"; import { randomBytes, randomUUID } from "node:crypto"; +import { access } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import type { Logger } from "pino"; +import { createExternalProcessEnv } from "../server/paseo-env.js"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; +import { writtenDuringThisBoot } from "../server/pid-lock.js"; import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; @@ -14,7 +17,28 @@ import type { TerminalSession, TerminalStateSnapshot, } from "./terminal.js"; +import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; +import { connectTerminalWorker, type TerminalWorkerConnection } from "./terminal-worker-client.js"; +import { launchTerminalWorker } from "./terminal-worker-launch.js"; +import { classifyTerminalWorkerProcess } from "./terminal-worker-ownership.js"; +import { + createTerminalWorkerProcessInspector, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { reapTerminalWorkers } from "./terminal-worker-reaper.js"; +import { + listTerminalWorkerRecords, + terminalWorkerRecordPath, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { + createTerminalWorkerPool, + NoTerminalWorkerAvailableError, + TerminalWorkerIncompatibleError, + type TerminalWorkerPoolMember, +} from "./terminal-worker-pool.js"; +import { pruneTerminalWorkerRuntimes } from "./terminal-worker-runtime.js"; import type { TerminalActivityListener, TerminalActivityTransitionEvent, @@ -25,7 +49,12 @@ import type { TerminalsChangedEvent, TerminalsChangedListener, } from "./terminal-manager.js"; +import { + classifyTerminalWorkerProtocol, + type TerminalWorkerProtocolFit, +} from "./terminal-worker-protocol.js"; import type { + TerminalWorkerListResult, TerminalWorkerRequest, TerminalWorkerResponse, TerminalWorkerToParentMessage, @@ -78,49 +107,45 @@ interface WorkerTerminalRecord { session: TerminalSession; } -interface TerminalWorkerProcess { - connected: boolean; - killed: boolean; - send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean; - disconnect(): void; - kill(): boolean; - on(event: "message", listener: (message: TerminalWorkerToParentMessage) => void): this; - on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +export interface TerminalCreateEnvironment { + baseEnv: Record; + paseoCliPath: string | null; } interface WorkerTerminalManagerOptions { + worker: TerminalWorkerConnection; + /** Reconnects a surviving worker; null means it is confirmed gone or the owner stopped. */ + reconnect?: () => Promise; requestTimeoutMs?: number; - forkWorker?: () => TerminalWorkerProcess; getTerminalActivityUrl?: () => string | null; + resolveCreateEnvironment?: () => TerminalCreateEnvironment; + // The kill path. The daemon passes the reaper, which verifies every program is gone; the + // default asks the connected worker to shut down. + closeTerminals?: () => Promise; +} + +/** The daemon's terminal manager: its terminals live in a worker that can outlive it. */ +export interface DaemonTerminalManager extends TerminalManager { + /** Registers every terminal the connected worker already runs. Resolves with their count. */ + adoptTerminals(): Promise; + /** Kills every terminal and verifies their programs ended. */ + closeTerminals(): Promise; + /** + * Lets go of the worker; its terminals keep running for the next daemon. With + * `afterPendingRequests`, requests already sent get their answers first. + */ + detach(options?: { afterPendingRequests?: boolean }): Promise; } function createActivityToken(): string { return randomBytes(32).toString("base64url"); } -function resolveWorkerUrl(): URL { - const currentUrl = import.meta.url; - if (currentUrl.endsWith(".ts")) { - return new URL("./terminal-worker-process.ts", currentUrl); - } - return new URL("./terminal-worker-process.js", currentUrl); -} - -function resolveWorkerExecArgv(): string[] { - if (!import.meta.url.endsWith(".ts")) { - return []; - } - const loaderUrl = new URL("./terminal-ts-loader.mjs", import.meta.url).href; - const importSource = [ - 'import { register } from "node:module";', - 'import { pathToFileURL } from "node:url";', - `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, - ].join(" "); - return [ - "--experimental-strip-types", - "--import", - `data:text/javascript,${encodeURIComponent(importSource)}`, - ]; +function resolveDaemonTerminalCreateEnvironment(): TerminalCreateEnvironment { + return { + baseEnv: createExternalProcessEnv(process.env), + paseoCliPath: resolvePaseoCliExecutablePath(), + }; } function isResponse(message: TerminalWorkerToParentMessage): message is TerminalWorkerResponse { @@ -138,20 +163,17 @@ function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerm }; } -function forkTerminalWorker(): TerminalWorkerProcess { - return fork(fileURLToPath(resolveWorkerUrl()), [], { - execArgv: resolveWorkerExecArgv(), - serialization: "advanced", - stdio: ["ignore", "ignore", "inherit", "ipc"], - }) as TerminalWorkerProcess; -} - export function createWorkerTerminalManager( - managerOptions: WorkerTerminalManagerOptions = {}, -): TerminalManager { - const worker = managerOptions.forkWorker ? managerOptions.forkWorker() : forkTerminalWorker(); + managerOptions: WorkerTerminalManagerOptions, +): DaemonTerminalManager { + let worker = managerOptions.worker; + let detached = false; + let reconnecting: Promise | null = null; const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; + const resolveCreateEnvironment = + managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment; const pendingRequests = new Map(); + const inFlight = new Set>(); const recordsById = new Map(); const terminalIdsByCwd = new Map>(); const terminalActivityTokenById = new Map(); @@ -162,8 +184,8 @@ export function createWorkerTerminalManager( const terminalActivityListeners = new Set(); const terminalWorkspaceContributionChangedListeners = new Set(); - let workerExited = false; - let workerShutdownTimer: ReturnType | null = null; + let workerDisconnected = false; + let closePromise: Promise | null = null; function emitTerminalsChanged(event: TerminalsChangedEvent): void { for (const listener of Array.from(terminalsChangedListeners)) { @@ -587,40 +609,77 @@ export function createWorkerTerminalManager( } } - worker.on("message", (message: TerminalWorkerToParentMessage) => { - if (isResponse(message)) { - const pending = pendingRequests.get(message.requestId); - if (!pending) { + function attachWorker(): void { + worker.on("message", (message: TerminalWorkerToParentMessage) => { + if (isResponse(message)) { + const pending = pendingRequests.get(message.requestId); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + pendingRequests.delete(message.requestId); + if (message.ok) { + pending.resolve(message.result); + } else { + pending.reject(new Error(message.error)); + } return; } - clearTimeout(pending.timeout); - pendingRequests.delete(message.requestId); - if (message.ok) { - pending.resolve(message.result); - } else { - pending.reject(new Error(message.error)); - } - return; - } - handleWorkerEvent(message); - }); + handleWorkerEvent(message); + }); - worker.on("exit", (code, signal) => { - workerExited = true; - if (workerShutdownTimer) { - clearTimeout(workerShutdownTimer); - workerShutdownTimer = null; - } - rejectPendingRequests(new Error(`Terminal worker exited (${signal ?? code ?? "unknown"})`)); - }); + worker.on("close", () => { + workerDisconnected = true; + rejectPendingRequests(new Error("Terminal worker connection closed")); + const reconnect = managerOptions.reconnect; + if (!detached && reconnect) { + reconnecting = (async () => { + const connection = await reconnect(); + if (!connection) { + for (const terminalId of Array.from(recordsById.keys())) { + handleTerminalExitEvent({ + type: "terminalExit", + terminalId, + info: { exitCode: null, signal: null, lastOutputLines: [] }, + }); + } + return; + } + if (detached) { + connection.disconnect(); + return; + } + worker = connection; + workerDisconnected = false; + attachWorker(); + await adoptTerminals(); + // Output emitted without a controller is retained in the worker's screen. Existing + // streams must fetch that screen before applying further live output. + for (const record of recordsById.values()) { + handleTerminalMessageEvent({ + type: "terminalMessage", + terminalId: record.info.id, + message: { type: "snapshotReady" }, + replayPreamble: record.replayPreamble, + }); + } + })().finally(() => { + reconnecting = null; + }); + void reconnecting.catch(() => {}); + } + }); + } + attachWorker(); function sendRequest(input: TerminalWorkerRequestInput): Promise { - if (workerExited || !worker.connected) { + if (reconnecting && workerDisconnected) return reconnecting.then(() => sendRequest(input)); + if (workerDisconnected || !worker.connected) { return Promise.reject(new Error("Terminal worker is not running")); } const requestId = randomUUID(); const message = { ...input, requestId } as TerminalWorkerRequest; - return new Promise((resolve, reject) => { + const request = new Promise((resolve, reject) => { const timeout = setTimeout(() => { pendingRequests.delete(requestId); reject(new Error(`Terminal worker request timed out: ${input.type}`)); @@ -635,6 +694,14 @@ export function createWorkerTerminalManager( reject(error); }); }); + // Tracks settlement only; the caller handles the rejection. + const settled = request.then( + () => undefined, + () => undefined, + ); + inFlight.add(settled); + void settled.finally(() => inFlight.delete(settled)); + return request; } function sendBestEffortRequest(input: TerminalWorkerRequestInput): void { @@ -645,6 +712,71 @@ export function createWorkerTerminalManager( }); } + function clearRecords(): void { + for (const terminalId of Array.from(recordsById.keys())) { + removeRecord(terminalId); + } + } + + function disconnectWorker(): Promise { + if (workerDisconnected || !worker.connected) { + return Promise.resolve(); + } + return new Promise((resolve) => { + worker.on("close", () => resolve()); + worker.disconnect(); + }); + } + + function closeTerminals(): Promise { + detached = true; + closePromise ??= (async () => { + await reconnecting; + if (managerOptions.closeTerminals) { + await disconnectWorker(); + clearRecords(); + await managerOptions.closeTerminals(); + return; + } + // Unverified: production passes the reaper, which confirms every program ended. + sendBestEffortRequest({ type: "shutdown" }); + await disconnectWorker(); + clearRecords(); + })(); + return closePromise; + } + + async function adoptTerminals(): Promise { + const listed = (await sendRequest({ type: "listTerminals" })) as TerminalWorkerListResult; + const cwds = new Set(); + const liveIds = new Set(listed.map((item) => item.terminal.id)); + for (const terminalId of Array.from(recordsById.keys())) { + if (!liveIds.has(terminalId)) + handleTerminalExitEvent({ + type: "terminalExit", + terminalId, + info: { exitCode: null, signal: null, lastOutputLines: [] }, + }); + } + for (const item of listed) { + const info = asRequiredWorkerTerminalInfo(item.terminal); + registerRecord({ info, state: item.state }); + const record = recordsById.get(info.id); + if (record) { + record.replayPreamble = item.replayPreamble; + } + // The agent hooks inside each shell keep the token they were started with. + if (item.activityToken) { + terminalActivityTokenById.set(info.id, item.activityToken); + } + cwds.add(info.cwd); + } + for (const cwd of cwds) { + emitTerminalsChanged({ cwd, terminals: listTerminalItemsForCwd(cwd) }); + } + return listed.length; + } + return { async getTerminals( cwd: string, @@ -688,6 +820,7 @@ export function createWorkerTerminalManager( const creation = { session: null as TerminalSession | null }; pendingCreationsById.set(terminalId, creation); try { + const environment = resolveCreateEnvironment(); await sendRequest({ type: "createTerminal", options: { @@ -695,6 +828,8 @@ export function createWorkerTerminalManager( id: terminalId, activityToken, activityUrl: terminalActivityUrl, + baseEnv: environment.baseEnv, + paseoCliPath: environment.paseoCliPath, }, }); if (!creation.session) { @@ -812,25 +947,25 @@ export function createWorkerTerminalManager( }, killAll(): void { - void sendRequest({ type: "killAll" }) - .catch(() => { - // no-op - }) - .finally(() => { - if (worker.connected) { - worker.disconnect(); - } - if (!worker.killed && !workerShutdownTimer) { - workerShutdownTimer = setTimeout(() => { - worker.kill(); - }, 1000); - } - }); - for (const terminalId of Array.from(recordsById.keys())) { - removeRecord(terminalId); - } + void closeTerminals().catch(() => { + // The synchronous interface cannot report it; closeTerminals() callers see the error. + }); }, + closeTerminals, + + async detach(options?: { afterPendingRequests?: boolean }): Promise { + detached = true; + await reconnecting; + if (options?.afterPendingRequests) { + await Promise.allSettled(Array.from(inFlight)); + } + await disconnectWorker(); + clearRecords(); + }, + + adoptTerminals, + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { terminalsChangedListeners.add(listener); return () => { @@ -856,6 +991,342 @@ export function createWorkerTerminalManager( }; } -export function terminateWorkerTerminalManager(manager: TerminalManager): void { - manager.killAll(); +export interface StartWorkerTerminalManagerOptions { + paseoHome: string; + paseoVersion: string; + logger: Logger; + getTerminalActivityUrl?: () => string | null; + requestTimeoutMs?: number; +} + +// The current worker plus one draining a previous protocol. Every live worker counts, +// incompatible, unreachable, unidentified and closing ones included: the daemon never starts a +// third. +const MAX_TERMINAL_WORKERS = 2; +const DRAINED_WORKER_EXIT_TIMEOUT_MS = 10_000; + +interface LiveWorkerRecord { + path: string; + record: TerminalWorkerRecord; + fit: TerminalWorkerProtocolFit; +} + +/** + * Connects the daemon to its terminal workers. A worker of this daemon's protocol is adopted + * (restart, crash, `stop --keep-terminals`); one a protocol older is adopted to drain; anything + * older is shut down, its terminals closed; one from a newer Paseo is left running and the + * terminal service reports it as incompatible. A worker is launched only when none is current, + * none is incompatible and fewer than two run; when two run, the first terminal created after one + * of them exits launches it. Records that cannot be adopted (a cleanup that + * never finished, a dead worker whose programs survived it) go through the reaper first, so a + * daemon never adopts terminals someone asked to close. + */ +export async function startWorkerTerminalManager( + options: StartWorkerTerminalManagerOptions, +): Promise { + const logger = options.logger.child({ module: "terminal-worker" }); + const inspector = createTerminalWorkerProcessInspector(); + const log = (message: string, fields?: Record) => logger.info(fields, message); + const live: LiveWorkerRecord[] = []; + const toReap: string[] = []; + for (const file of await listTerminalWorkerRecords(options.paseoHome)) { + if ("error" in file) { + logger.warn({ recordPath: file.path, err: file.error }, "Unreadable terminal worker record"); + continue; + } + const { record } = file; + if ( + record.closing || + !writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId }) + ) { + toReap.push(file.path); + continue; + } + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (worker.status === "owned") { + live.push({ + path: file.path, + record, + fit: classifyTerminalWorkerProtocol(record.protocolVersion), + }); + } else if (worker.status === "unknown") { + logger.warn( + { recordPath: file.path, pid: record.pid, reason: worker.reason }, + "Cannot identify terminal worker; leaving its record", + ); + } else { + // Its programs lost their PTY with the worker and cannot be adopted. + toReap.push(file.path); + } + } + + if (toReap.length > 0) { + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + gracefulMs: 0, + recordPaths: toReap, + inspector, + log, + }).catch((error: unknown) => { + logger.warn({ err: error }, "Terminal cleanup of an unadoptable worker was not confirmed"); + }); + } + + const obsolete = live.filter((entry) => entry.fit === "obsolete"); + if (obsolete.length > 0) { + for (const entry of obsolete) { + logger.warn( + { + pid: entry.record.pid, + protocolVersion: entry.record.protocolVersion, + paseoVersion: entry.record.paseoVersion, + terminals: entry.record.terminals.length, + }, + "Closing a terminal worker two or more protocol versions old; its terminals close", + ); + } + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + recordPaths: obsolete.map((entry) => entry.path), + inspector, + log, + }).catch((error: unknown) => { + logger.warn({ err: error }, "Closing an obsolete terminal worker was not confirmed"); + }); + } + + const closeAll = async () => { + const result = await reapTerminalWorkers({ paseoHome: options.paseoHome, inspector, log }); + logger.info(result, "Terminals closed"); + }; + const prune = async () => { + const result = await pruneTerminalWorkerRuntimes(options.paseoHome); + if (result.removed.length > 0) + log("Removed terminal worker copies", { removed: result.removed }); + for (const failure of result.failed) { + logger.warn(failure, "Cannot remove a terminal worker copy; the next start retries"); + } + }; + + const pruneAfterExit = async (workerPid: number) => { + if (await waitForWorkerRecordRemoval(options.paseoHome, workerPid)) { + await prune(); + return; + } + logger.warn({ pid: workerPid }, "Drained terminal worker has not exited yet"); + }; + + const newer = live.find((entry) => entry.fit === "newer"); + if (newer) { + const incompatible = new TerminalWorkerIncompatibleError({ + paseoVersion: newer.record.paseoVersion, + protocolVersion: newer.record.protocolVersion, + }); + logger.error({ pid: newer.record.pid }, incompatible.message); + await prune(); + return createTerminalWorkerPool({ + current: null, + draining: [], + incompatible, + closeAll, + }); + } + + const createMember = async ( + record: TerminalWorkerRecord, + connection: TerminalWorkerConnection, + ): Promise => { + let alive = true; + let resolveExit!: () => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + let recovery: Promise | null = null; + const recoveryState = { stopped: false }; + const reconnect = () => { + recovery ??= (async () => { + while (!recoveryState.stopped) { + const ownership = classifyTerminalWorkerProcess( + record, + await inspector.inspect(record.pid), + ); + if (ownership.status === "gone" || ownership.status === "unrelated") { + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + recordPaths: [terminalWorkerRecordPath(options.paseoHome, record.pid)], + inspector, + log, + gracefulMs: 0, + }).catch((error) => + logger.warn({ err: error }, "Dead terminal worker cleanup was not confirmed"), + ); + alive = false; + resolveExit(); + return null; + } + try { + return await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + timeoutMs: 500, + }); + } catch { + await delay(100); + } + } + return null; + })().finally(() => { + recovery = null; + }); + return recovery; + }; + const manager = createWorkerTerminalManager({ + worker: connection, + reconnect, + ...(options.requestTimeoutMs === undefined + ? {} + : { requestTimeoutMs: options.requestTimeoutMs }), + ...(options.getTerminalActivityUrl + ? { getTerminalActivityUrl: options.getTerminalActivityUrl } + : {}), + closeTerminals: closeAll, + }); + const count = await manager.adoptTerminals(); + if (count > 0) { + logger.info({ pid: record.pid, terminals: count }, "Adopted terminals from a running worker"); + } + return { + manager, + workerPid: record.pid, + exited, + ready: async () => { + await recovery; + return alive && !recoveryState.stopped; + }, + stopRecovery: () => { + recoveryState.stopped = true; + }, + }; + }; + + let current: TerminalWorkerPoolMember | null = null; + const draining: TerminalWorkerPoolMember[] = []; + const adoptable = [ + ...live.filter((entry) => entry.fit === "current"), + ...live.filter((entry) => entry.fit === "previous"), + ]; + for (const entry of adoptable) { + let connection: TerminalWorkerConnection; + try { + connection = await connectTerminalWorker({ + socketPath: entry.record.socketPath, + secret: entry.record.secret, + }); + } catch (error) { + logger.warn( + { pid: entry.record.pid, err: error }, + "Cannot reach terminal worker; leaving it running", + ); + continue; + } + const member = await createMember(entry.record, connection); + if (entry.fit === "current" && !current) { + logger.info({ pid: entry.record.pid }, "Adopting running terminal worker"); + current = member; + } else { + // A previous protocol, or a second worker of this one: it keeps its terminals and takes + // no new ones. + logger.info( + { pid: entry.record.pid, protocolVersion: entry.record.protocolVersion }, + "Draining terminal worker; it exits after its last terminal", + ); + draining.push(member); + } + } + + // Counted from the records rather than from what startup drove: a worker a cleanup could not + // confirm gone keeps running, and a draining one exits on its own. + const startCurrent = async (): Promise => { + const running = await countRunningTerminalWorkers(options.paseoHome, inspector); + if (running >= MAX_TERMINAL_WORKERS) { + throw new NoTerminalWorkerAvailableError( + `${running} terminal workers are already running in ${options.paseoHome}; new terminals can open once one of them exits`, + ); + } + const launched = await launchTerminalWorker({ + paseoHome: options.paseoHome, + paseoVersion: options.paseoVersion, + }); + const connection = await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }); + logger.info( + { pid: launched.record.pid, runtime: launched.record.runtime ?? null }, + "Launched terminal worker", + ); + return createMember(launched.record, connection); + }; + + await prune(); + if (!current) { + try { + current = await startCurrent(); + } catch (error) { + if (!(error instanceof NoTerminalWorkerAvailableError)) throw error; + logger.warn(error.message); + } + } + + return createTerminalWorkerPool({ + current, + draining, + incompatible: null, + startCurrent, + closeAll, + onDrained: (member) => { + log("Released drained terminal worker", { pid: member.workerPid }); + void pruneAfterExit(member.workerPid); + }, + }); +} + +// Every worker that may still run in this home, one per record: a worker and its cleanup record +// are listed as one. Unreadable records and workers that cannot be identified count; a worker +// confirmed gone, or a pid reused by another process, does not. +async function countRunningTerminalWorkers( + paseoHome: string, + inspector: TerminalWorkerProcessInspector, +): Promise { + let running = 0; + for (const file of await listTerminalWorkerRecords(paseoHome)) { + if ("error" in file) { + running += 1; + continue; + } + const { record } = file; + if (!writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId })) continue; + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (worker.status === "owned" || worker.status === "unknown") running += 1; + } + return running; +} + +// A worker deletes its own record as it exits. +async function waitForWorkerRecordRemoval(paseoHome: string, workerPid: number): Promise { + const recordPath = terminalWorkerRecordPath(paseoHome, workerPid); + const deadline = Date.now() + DRAINED_WORKER_EXIT_TIMEOUT_MS; + while ( + await access(recordPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ) + ) { + if (Date.now() >= deadline) return false; + await delay(100); + } + return true; } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index eed39f63e5..358bd6eb69 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -45,6 +45,8 @@ import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, + findPaseoWorktreeRootForCwd, + hasPaseoWorktreeMetadata, getPaseoWorktreeChangeRequestHintForBranch, type PaseoWorktreeMetadata, readPaseoWorktreeMetadata, @@ -1126,9 +1128,17 @@ async function getMainRepoRootFromCommonDir( }, ); const worktrees = parseWorktreeList(worktreeOut); + const paseoPaths = new Set( + ( + await Promise.all( + worktrees.map(async (wt) => ((await hasPaseoWorktreeMetadata(wt.path)) ? wt.path : null)), + ) + ).filter((p) => p !== null), + ); const nonBareNonPaseo = worktrees.filter( (wt) => !wt.isBare && + !paseoPaths.has(wt.path) && !isPaseoWorktreePath(wt.path, { paseoHome: context?.paseoHome, worktreesRoot: context?.worktreesRoot, @@ -1257,7 +1267,7 @@ async function getPaseoWorktreeForCwd( options: PaseoWorktreeLookupOptions = {}, ): Promise { // Fast-path reject: non-worktree paths do not need expensive ownership checks. - if (!/[\\/]worktrees[\\/]/.test(cwd)) { + if (!/[\\/]worktrees[\\/]/.test(cwd) && (await findPaseoWorktreeRootForCwd(cwd)) === null) { return { isPaseoOwnedWorktree: false }; } diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 13c95567db..fcb0454672 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -2,7 +2,7 @@ import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import pLimit from "p-limit"; -import { isAbsolute, join, resolve } from "path"; +import { dirname, isAbsolute, join, resolve } from "path"; import { z } from "zod"; const MAX_WORKTREE_METADATA_BYTES = 64 * 1024; @@ -329,6 +329,39 @@ export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( }); } +export async function hasPaseoWorktreeMetadata(worktreeRoot: string): Promise { + try { + return (await readPaseoWorktreeMetadata(worktreeRoot)) !== null; + } catch { + return false; + } +} + +/** + * Filesystem-only search for a Paseo worktree root at or above cwd: the + * first directory containing a .git entry wins, and it only counts when + * its git admin dir holds the paseo/worktree.json marker. Lets callers + * cheaply rule out plain directories before running git ownership checks. + */ +export async function findPaseoWorktreeRootForCwd(cwd: string): Promise { + let current = resolve(cwd); + for (;;) { + if ( + await stat(join(current, ".git")).then( + () => true, + () => false, + ) + ) { + return (await hasPaseoWorktreeMetadata(current)) ? current : null; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + export async function readPaseoWorktreeMetadata( worktreeRoot: string, ): Promise { diff --git a/packages/server/src/utils/worktree-path.ts b/packages/server/src/utils/worktree-path.ts new file mode 100644 index 0000000000..d36abea762 --- /dev/null +++ b/packages/server/src/utils/worktree-path.ts @@ -0,0 +1,88 @@ +import { realpath, lstat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path"; +import { getRelativePathInsideRoot } from "./path.js"; + +async function normalizeAbsolute(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +// Resolve a path that does not exist yet (the normal case for a worktree +// destination): realpath the nearest existing ancestor, then append the +// missing tail. A plain resolve() would let a symlinked parent smuggle +// the destination past the denylist — e.g. /alias -> /.git. +async function normalizeCandidatePath(path: string): Promise { + let current = resolve(path); + const missing: string[] = []; + while (!(await pathExists(current))) { + missing.unshift(basename(current)); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + const resolvedAncestor = await normalizeAbsolute(current); + return missing.length > 0 ? join(resolvedAncestor, ...missing) : resolvedAncestor; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +/** + * Validate a worktree destination. Throws + * with the reason on any violation; returns the normalized absolute path. + */ +export async function validateWorktreePath(options: { + candidate: string; + repoPath: string; +}): Promise { + const { candidate, repoPath } = options; + if (!isAbsolute(candidate)) { + throw new Error( + `Worktree hook returned a relative path '${candidate}'; it must be an absolute path`, + ); + } + const normalized = await normalizeCandidatePath(candidate); + const normalizedRepoPath = await normalizeAbsolute(repoPath); + + if (normalized === parse(normalized).root) { + throw new Error("Worktree hook returned the filesystem root, which is not allowed"); + } + if (normalized === (await normalizeAbsolute(homedir()))) { + throw new Error("Worktree hook returned the user's home directory, which is not allowed"); + } + if (normalized === normalizedRepoPath) { + throw new Error("Worktree hook returned the repository root itself, which is not allowed"); + } + if ( + [normalized, resolve(candidate)].some((path) => + path + .split(sep) + .some((part) => (process.platform === "win32" ? part.toLowerCase() : part) === ".git"), + ) + ) { + throw new Error(`Worktree hook returned a path inside a .git directory: ${normalized}`); + } + const relativeToCandidate = getRelativePathInsideRoot(normalized, normalizedRepoPath); + if (relativeToCandidate !== null && relativeToCandidate !== "") { + throw new Error(`Worktree hook returned an ancestor of the repository: ${normalized}`); + } + if ((await pathExists(normalized)) || (await pathExists(candidate))) { + throw new Error( + `Worktree hook returned a path that already exists: ${normalized}. Paseo creates the returned path.`, + ); + } + return normalized; +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index aede7cf8e3..35d59cce3f 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1,5 +1,6 @@ // POSIX-only: git worktree and teardown shell fixtures /* eslint-disable max-nested-callbacks */ +import { validateWorktreePath } from "./worktree-path.js"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createWorktree as createWorktreePrimitive, @@ -159,7 +160,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) }); await expect( isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }), - ).resolves.toMatchObject({ allowed: false }); + ).resolves.toMatchObject({ allowed: true, worktreePath: result.worktreePath }); const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot }); expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath); @@ -1380,6 +1381,200 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + describe("worktrees at explicit paths", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-explicit-test-"))); + repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); + + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("places, owns, lists, and deletes a worktree outside the legacy root", async () => { + // Sibling layout: /wt- + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "sibling-one", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/sibling" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + + expect(result.worktreePath).toBe(join(tempDir, "wt-sibling-one")); + expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); + expect(existsSync(await getPaseoWorktreeMetadataPath(result.worktreePath))).toBe(true); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.worktreePath).toBe(result.worktreePath); + // A subdirectory of the worktree resolves to the worktree root. + mkdirSync(join(result.worktreePath, "sub"), { recursive: true }); + const nested = await isPaseoOwnedWorktreeCwd(join(result.worktreePath, "sub"), { + paseoHome, + }); + expect(nested.worktreePath).toBe(result.worktreePath); + + const listed = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); + expect(listed.map((entry) => entry.path)).toContain(result.worktreePath); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: result.worktreePath, paseoHome }); + expect(existsSync(result.worktreePath)).toBe(false); + }); + + it("removes branches fetched by the source plan when git worktree add fails", async () => { + // A branch that exists only on the remote: resolving a + // checkout-branch plan fetches it into a local branch. A script + // failure must not leave that branch behind, or the next attempt + // would uniquify to a different branch name. + const remoteDir = join(tempDir, "remote.git"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { cwd: tempDir }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["branch", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["push", "origin", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["branch", "-D", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + + mkdirSync(join(tempDir, "occupied")); + writeFileSync(join(tempDir, "occupied", "keep"), "keep"); + await expect( + createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "remote-check", + source: { kind: "checkout-branch", branchName: "feature/remote-check" }, + paseoHome, + worktreePath: join(tempDir, "occupied"), + }), + ).rejects.toThrow(); + + const localBranches = execFileSync("git", ["branch", "--list", "feature/remote-check"], { + cwd: repoDir, + encoding: "utf8", + }); + expect(localBranches.trim()).toBe(""); + }); + + it("deletes only the registered destination nested beneath the legacy root", async () => { + const parent = join(paseoHome, "worktrees", "acme", "widgets"); + const worktreePath = join(parent, "topic"); + const siblingPath = join(parent, "sibling"); + await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "topic", + worktreePath, + source: { kind: "branch-off", branchName: "topic", baseBranch: "main" }, + }); + await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "sibling", + worktreePath: siblingPath, + source: { kind: "branch-off", branchName: "sibling", baseBranch: "main" }, + }); + mkdirSync(join(worktreePath, "nested")); + expect( + await isPaseoOwnedWorktreeCwd(join(worktreePath, "nested"), { paseoHome }), + ).toMatchObject({ allowed: true, worktreePath }); + await deletePaseoWorktree({ cwd: repoDir, paseoHome, worktreePath, registryProven: true }); + expect(existsSync(worktreePath)).toBe(false); + expect(readFileSync(join(siblingPath, "file.txt"), "utf8")).toBe("hello\n"); + }); + + it("rejects a destination under a symlink into .git", async () => { + const alias = join(tempDir, "git-alias"); + symlinkSync(join(repoDir, ".git"), alias, "dir"); + await expect( + validateWorktreePath({ candidate: join(alias, "new", "worktree"), repoPath: repoDir }), + ).rejects.toThrow("inside a .git directory"); + expect(existsSync(join(repoDir, ".git", "new"))).toBe(false); + }); + + it("uses an explicit worktreePath verbatim", async () => { + const explicitPath = join(tempDir, "explicit-wt"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "explicit-one", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/explicit" }, + paseoHome, + worktreePath: explicitPath, + }); + + expect(result.worktreePath).toBe(explicitPath); + expect(existsSync(join(explicitPath, "file.txt"))).toBe(true); + }); + + it("restore recreates at the recorded path", async () => { + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "restorable", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/restorable" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + expect(existsSync(created.worktreePath)).toBe(false); + + const restored = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "restorable", + source: { kind: "restore", branchName: "feature/restorable", baseRef: "main" }, + paseoHome, + worktreePath: created.worktreePath, + }); + + expect(restored.worktreePath).toBe(created.worktreePath); + expect(existsSync(join(restored.worktreePath, "file.txt"))).toBe(true); + }); + + it("does not treat a copied marker in a non-worktree directory as ownership", async () => { + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "marker-source", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/marker" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + const metadata = await readPaseoWorktreeMetadata(created.worktreePath); + expect(metadata).not.toBeNull(); + + // A plain directory with a .git file pointing at a copied admin dir + // that holds a paseo/worktree.json. Git does not list it as a + // worktree, so the copied marker must not prove ownership. + const fakeDir = join(tempDir, "fake-worktree"); + const fakeAdmin = join(tempDir, "fake-admin"); + mkdirSync(join(fakeAdmin, "paseo"), { recursive: true }); + mkdirSync(fakeDir, { recursive: true }); + writeFileSync(join(fakeAdmin, "paseo", "worktree.json"), JSON.stringify(metadata)); + writeFileSync(join(fakeDir, ".git"), `gitdir: ${fakeAdmin}\n`); + + const ownership = await isPaseoOwnedWorktreeCwd(fakeDir, { paseoHome }); + expect(ownership.allowed).toBe(false); + await expect( + deletePaseoWorktree({ cwd: repoDir, worktreePath: fakeDir, paseoHome }), + ).rejects.toThrow("Refusing to delete non-Paseo worktree"); + expect(existsSync(fakeDir)).toBe(true); + }); + }); + describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 605e2b8c20..f549b461f8 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -302,6 +302,22 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + it("deletes a worktree when Git reports its source through native and long path spellings", async () => { + const created = await createLegacyWorktreeForTest({ + branchName: "identity-delete", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "identity-delete", + paseoHome, + }); + await deletePaseoWorktree({ + cwd: realpathSync.native(repoDir), + worktreePath: created.worktreePath, + paseoHome, + }); + expect(existsSync(created.worktreePath)).toBe(false); + }); + it("is idempotent: deleting an already-absent worktree succeeds", async () => { const created = await createLegacyWorktreeForTest({ branchName: "idempotent-delete-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 3ddc71b472..2fc8bc05db 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -24,6 +24,8 @@ export { import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { createPaseoWorktreeChangeRequestHint, + hasPaseoWorktreeMetadata, + findPaseoWorktreeRootForCwd, normalizeBaseRefName, isQualifiedRef, type PaseoWorktreeChangeRequestHint, @@ -218,6 +220,7 @@ export interface CreateWorktreeOptions { source: WorktreeSource; paseoHome?: string; worktreesRoot?: string; + worktreePath?: string; } export class BranchAlreadyCheckedOutError extends Error { @@ -964,6 +967,24 @@ export async function isPaseoOwnedWorktreeCwd( } } + // Worktrees placed at plugin-chosen paths are + // proven by the marker createWorktree writes unconditionally: a + // paseo/worktree.json in the worktree's git admin dir, with the path + // still registered in the repo's `git worktree list`. The marker alone + // is not proof — it lives in a git admin dir a user can copy into a + // hand-made directory that git does not know about. + if (options?.knownGitCommonDir !== null) { + const metadataProof = await resolveMetadataOwnedWorktree(cwd, repoRoot); + if (metadataProof) { + return { + allowed: true, + ...(repoRoot !== undefined ? { repoRoot } : {}), + worktreeRoot: metadataProof.worktreeRoot, + worktreePath: metadataProof.worktreePath, + }; + } + } + const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd); @@ -971,32 +992,51 @@ export async function isPaseoOwnedWorktreeCwd( // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. - if (relativePath === null) { - return { - allowed: false, - ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreePath: resolvedCwd, - }; + if (relativePath !== null) { + const parts = relativePath.split(sep).filter((part) => part.length > 0); + if (parts.length >= 2) { + const worktreesRoot = join(worktreesBaseRoot, parts[0]); + return { + allowed: true, + ...(repoRoot !== undefined ? { repoRoot } : {}), + worktreeRoot: worktreesRoot, + worktreePath: join(worktreesRoot, parts[1]), + }; + } } - const parts = relativePath.split(sep).filter((part) => part.length > 0); - if (parts.length < 2) { - return { - allowed: false, - ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreePath: resolvedCwd, - }; - } - - const worktreesRoot = join(worktreesBaseRoot, parts[0]); return { - allowed: true, + allowed: false, ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreeRoot: worktreesRoot, - worktreePath: join(worktreesRoot, parts[1]), + worktreePath: resolvedCwd, }; } +async function resolveMetadataOwnedWorktree( + cwd: string, + repoRoot: string | undefined, +): Promise<{ worktreePath: string; worktreeRoot: string } | null> { + try { + const root = await findPaseoWorktreeRootForCwd(cwd); + if (!root) return null; + const candidate = await normalizePathForOwnership(root); + if (repoRoot !== undefined && candidate === (await normalizePathForOwnership(repoRoot))) + return null; + const { stdout: listOutput } = await runGitCommand(["worktree", "list", "--porcelain"], { + cwd: candidate, + envOverlay: READ_ONLY_GIT_ENV, + }); + for (const entry of parseWorktreeList(listOutput)) { + if ((await normalizePathForOwnership(entry.path)) === candidate) { + return { worktreePath: candidate, worktreeRoot: dirname(candidate) }; + } + } + return null; + } catch { + return null; + } +} + type ParsedPaseoWorktreeInfo = Omit; function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { @@ -1063,12 +1103,17 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); + const mainCheckoutPath = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd)); const roots = await ownershipPathVariants(projectWorktreesRoot); const worktrees: PaseoWorktreeInfo[] = []; // Inspect foreign paths sequentially; a large worktree list must not consume the FS pool. for (const entry of parseWorktreeList(stdout)) { const path = await normalizePathForOwnership(entry.path); - if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue; + if ( + !roots.some((root) => getRelativePathInsideRoot(root, path) !== null) && + !(path !== mainCheckoutPath && (await hasPaseoWorktreeMetadata(path))) + ) + continue; worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) }); } return worktrees; @@ -1082,6 +1127,30 @@ export interface DeletePaseoWorktreeOptions { worktreesRoot?: string; paseoHome?: string; worktreesBaseRoot?: string; + // The caller has a workspace registry record backing worktreePath (the + // record's cwd is this path). Registry membership is an ownership proof + // in its own right: it covers worktrees whose git admin dir is already + // gone, where the marker proof can no longer be verified. Only pass + // this when the record was matched against the path by the caller. + registryProven?: boolean; +} + +// Project scoping for delete: a proof for some other repo's worktree must +// not make this repo's delete remove it. Only refuses on a positive +// mismatch — when git has forgotten either side, the ownership proof the +// caller already established stands. +async function assertWorktreeBelongsToRepo(cwd: string, worktreePath: string): Promise { + let callerRepoRoot: string; + let targetRepoRoot: string; + try { + callerRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd)); + targetRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(worktreePath)); + } catch { + return; + } + if ((await getOwnershipRelativePath(callerRepoRoot, targetRepoRoot)) !== "") { + throw new Error("Refusing to delete non-Paseo worktree"); + } } export async function deletePaseoWorktree({ @@ -1092,6 +1161,7 @@ export async function deletePaseoWorktree({ worktreesRoot, paseoHome, worktreesBaseRoot, + registryProven, }: DeletePaseoWorktreeOptions): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); @@ -1110,22 +1180,27 @@ export async function deletePaseoWorktree({ } const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); - const resolvedRequested = await normalizePathForOwnership(requestedPath); const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { paseoHome, worktreesRoot: worktreesBaseRoot, }); - const resolvedWorktree = - ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - - const relativeWorktreePath = await getOwnershipRelativePath( - resolvedWorktreesRoot, - resolvedWorktree, - ); - if (relativeWorktreePath === null || relativeWorktreePath === "") { + // Deletion is gated on the ownership proof set (legacy shape, the + // paseo/worktree.json marker verified against `git worktree list`, or a + // caller-asserted registry record), not on living under the per-project + // hashed root: plugin-placed worktrees live anywhere. + let resolvedWorktree: string; + if (registryProven) { + resolvedWorktree = await normalizePathForOwnership(requestedPath); + } else if (ownership.allowed && ownership.worktreePath) { + resolvedWorktree = ownership.worktreePath; + } else { throw new Error("Refusing to delete non-Paseo worktree"); } + if (cwd) { + await assertWorktreeBelongsToRepo(cwd, resolvedWorktree); + } + if (await pathExists(resolvedWorktree)) { for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) { await runWorktreeTeardownCommands({ @@ -1222,6 +1297,67 @@ async function removeDirectoryWithRetries(path: string): Promise { } } +async function listLocalBranchNames(cwd: string): Promise> { + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:short)", "refs/heads"], + { cwd }, + ); + return new Set( + stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ); +} + +// The one local branch a source plan can create while resolving: a +// checkout-branch plan fetches refs/heads/, and a +// change-request/PR plan fetches refs/heads/ (which is +// the plan's branchName). branch-off creates no local branch. Only this +// ref may be rolled back when creation fails — never any other +// branch that appeared in the meantime; other creators own those. +function planCreatedLocalBranch( + source: WorktreeSource, + sourcePlan: WorktreeSourcePlan, +): string | null { + switch (source.kind) { + case "checkout-branch": + return source.branchName; + case "checkout-change-request": + case "checkout-github-pr": + return sourcePlan.branchName; + default: + return null; + } +} + +async function readLocalBranchTip(cwd: string, branchName: string): Promise { + try { + const { stdout } = await runGitCommand( + ["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`], + { cwd }, + ); + const tip = stdout.trim(); + return tip.length > 0 ? tip : null; + } catch { + return null; + } +} + +/** Resolve the default placement once, before presenting it to creation hooks. */ +export async function resolveDefaultWorktreePath( + options: Pick, +): Promise { + const original = join( + await getPaseoWorktreesRoot(options.cwd, options.paseoHome, options.worktreesRoot), + options.worktreeSlug, + ); + let candidate = original; + let suffix = 1; + while (await pathExists(candidate)) candidate = `${original}-${suffix++}`; + return candidate; +} + /** * Create a git worktree with proper naming conventions */ @@ -1231,56 +1367,73 @@ export const createWorktree = async ({ worktreeSlug, paseoHome, worktreesRoot, + worktreePath: explicitWorktreePath, }: CreateWorktreeOptions): Promise => { + const branchesBefore = + source.kind === "checkout-branch" || + source.kind === "checkout-change-request" || + source.kind === "checkout-github-pr" + ? await listLocalBranchNames(cwd) + : new Set(); const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - await mkdir(dirname(worktreePath), { recursive: true }); + const fetchedBranch = planCreatedLocalBranch(source, sourcePlan); + const fetchedTip = + fetchedBranch && !branchesBefore.has(fetchedBranch) + ? await readLocalBranchTip(cwd, fetchedBranch) + : null; + try { + let worktreePath = + explicitWorktreePath ?? + (await resolveDefaultWorktreePath({ cwd, paseoHome, worktreesRoot, worktreeSlug })); + await mkdir(dirname(worktreePath), { recursive: true }); - // Also handle worktree path collision - let finalWorktreePath = worktreePath; - let pathSuffix = 1; - while (await pathExists(finalWorktreePath)) { - finalWorktreePath = `${worktreePath}-${pathSuffix}`; - pathSuffix++; - } - - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - worktreePath = await normalizePathForOwnership(finalWorktreePath); - - if (sourcePlan.pushRemote) { - await configureWorktreePushRemote({ + // Primitive owner for `git worktree add`; callers route through createWorktreeCore. + await runGitCommand(["worktree", "add", worktreePath, ...sourcePlan.addArguments], { cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.pushRemote, + timeout: 120_000, }); - } - if (sourcePlan.trackingRemote) { - await configureWorktreeTrackingRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.trackingRemote, + worktreePath = await normalizePathForOwnership(worktreePath); + + if (sourcePlan.pushRemote) { + await configureWorktreePushRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.pushRemote, + }); + } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } + + await writePaseoWorktreeMetadata(worktreePath, { + baseRefName: sourcePlan.metadataBaseRefName, + ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), + ...(sourcePlan.changeRequestLookupTarget + ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } + : {}), }); + + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + + return { + branchName: sourcePlan.branchName, + worktreePath, + comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, + }; + } catch (error) { + if ( + fetchedBranch && + fetchedTip && + (await readLocalBranchTip(cwd, fetchedBranch)) === fetchedTip + ) { + await runGitCommand(["branch", "-D", fetchedBranch], { cwd }).catch(() => {}); + } + throw error; } - - await writePaseoWorktreeMetadata(worktreePath, { - baseRefName: sourcePlan.metadataBaseRefName, - ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), - ...(sourcePlan.changeRequestLookupTarget - ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } - : {}), - }); - - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - - return { - branchName: sourcePlan.branchName, - worktreePath, - comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, - }; }; interface ResolveWorktreeSourcePlanOptions { diff --git a/packages/server/tsconfig.scripts.json b/packages/server/tsconfig.scripts.json index cc8e24efd4..6a75825b00 100644 --- a/packages/server/tsconfig.scripts.json +++ b/packages/server/tsconfig.scripts.json @@ -19,6 +19,11 @@ "declaration": false, "sourceMap": true }, - "include": ["scripts/supervisor-entrypoint.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], + "include": [ + "scripts/supervisor-entrypoint.ts", + "scripts/dev-runner.ts", + "scripts/supervisor.ts", + "src/executable-resolution/which.d.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/packages/server/tsconfig.server.json b/packages/server/tsconfig.server.json index 9c868b42df..1c7a5c3579 100644 --- a/packages/server/tsconfig.server.json +++ b/packages/server/tsconfig.server.json @@ -22,7 +22,12 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/server/**/*", "src/services/**/*", "src/terminal/**/*"], + "include": [ + "src/server/**/*", + "src/services/**/*", + "src/terminal/**/*", + "src/executable-resolution/which.d.ts" + ], "exclude": [ "node_modules", "dist", diff --git a/packages/website/src/plugin-docs-navigation.test.ts b/packages/website/src/plugin-docs-navigation.test.ts index 0aeed8ff88..93f0cf7736 100644 --- a/packages/website/src/plugin-docs-navigation.test.ts +++ b/packages/website/src/plugin-docs-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs"; +import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs"; describe("plugin documentation", () => { it("groups current guides by task without version navigation", () => { @@ -14,11 +14,20 @@ describe("plugin documentation", () => { { type: "page", label: "Publishing", href: "/docs/plugins/publishing" }, { type: "page", label: "Provider plugins", href: "/docs/plugins/providers" }, { type: "page", label: "Migration", href: "/docs/plugins/migration" }, + { type: "page", label: "Hooks", href: "/docs/plugins/hooks" }, { type: "page", label: "Reference", href: "/docs/plugins/reference" }, ], }); }); + it.each(["en", "zh-CN"] as const)("resolves worktree placement hook links in %s", (locale) => { + const hooks = getDoc("plugins/hooks", locale)!; + expect(parseHeadings(hooks.content).map((heading) => heading.id)).toContain( + "worktreecreatebefore", + ); + expect(hooks.content).toContain("plugin-examples/worktree-placement"); + }); + it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => { for (const page of ["", "/index", "/reference", "/providers", "/migration"]) { const target = `/docs/plugins${page === "/index" ? "" : page}`; @@ -32,7 +41,7 @@ describe("plugin documentation", () => { }); it("serves canonical pages without redirect loops", () => { - for (const page of ["", "/publishing", "/reference", "/providers", "/migration"]) { + for (const page of ["", "/publishing", "/reference", "/providers", "/migration", "/hooks"]) { expect(getLegacyDocsRedirect(`/docs/plugins${page}`)).toBeUndefined(); expect(getLegacyDocsRedirect(`/docs/plugins${page}.md`)).toBeUndefined(); } diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index 2a34c1222d..cb191bd3e1 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -1,15 +1,15 @@ # Agent configuration -Sets saved Codex options through `server.before("agent.create", ...)`: +Sets saved Codex options through `server.addHook("agent.create.before", async ...)`: - Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.approval_policy` to `on-request`. - Preserves other configuration and provider options. Adds the `company` MCP server to Codex launches through -`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration; +`server.addHook("agent.open.before", async ...)`. The server is never saved in the agent's configuration; existing saved MCP servers are preserved. Existing agents receive the server through the -[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents). +[session reopen behavior](../../public-docs/plugins/hooks.md#attach-tools-to-agents). Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using this example. A `company` name collision with a saved server or another @@ -26,7 +26,7 @@ entries added by this plugin's earlier callbacks. The create callback receives c earlier plugins have transformed it; its explicit Codex options replace the named fields in the saved configuration of new agents and remain after this plugin is disabled. -See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing +See [Inject MCP servers](../../public-docs/plugins/hooks.md#inject-mcp-servers) for choosing between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and environment injection. Its provider-switch callback clears provider options when changing Codex to Claude, so use these examples separately when trying the diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index bc5abc9e3d..cfb236caa1 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -1,7 +1,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider !== "codex") { return request; } @@ -19,7 +19,7 @@ export default function contribute(server: PluginServerContext) { }; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.provider !== "codex") { return; } diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json index 019394b8f7..98e1460c12 100644 --- a/plugin-examples/agent-configuration/paseo-plugin.json +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "agent-configuration", "requirements": { - "paseo": ">=0.13.2" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/agent-tools/README.md b/plugin-examples/agent-tools/README.md index e6ece3978e..e8674a9d66 100644 --- a/plugin-examples/agent-tools/README.md +++ b/plugin-examples/agent-tools/README.md @@ -29,5 +29,5 @@ From the repository root, `npm run typecheck` includes this example. Outside thi use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts` and the manifest into that project. -See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents) +See [Attach tools to agents](../../public-docs/plugins/hooks.md#attach-tools-to-agents) for targeting, execution, provider gates, collisions, and plugin reload behavior. diff --git a/plugin-examples/agent-tools/index.server.ts b/plugin-examples/agent-tools/index.server.ts index 600d743fbd..1bede92569 100644 --- a/plugin-examples/agent-tools/index.server.ts +++ b/plugin-examples/agent-tools/index.server.ts @@ -18,7 +18,7 @@ const appendWorkspaceNote = defineAgentTool({ }); export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels["notes/enabled"] !== "true") return; return { ...request, tools: [...request.tools, appendWorkspaceNote] }; }); diff --git a/plugin-examples/agent-tools/paseo-plugin.json b/plugin-examples/agent-tools/paseo-plugin.json index eaec7ffaf6..e67c53f072 100644 --- a/plugin-examples/agent-tools/paseo-plugin.json +++ b/plugin-examples/agent-tools/paseo-plugin.json @@ -1,4 +1,4 @@ { "id": "agent-tools", - "requirements": { "paseo": ">=0.13.2" } + "requirements": { "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index c97f2a7254..7b1fb1e761 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -2,7 +2,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText, shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") { return { ...request, @@ -17,7 +17,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.on("agent.turn_ended", async (event, context) => { + server.addHook("agent.turn.after", async (event, context) => { if (event.outcome.kind === "canceled") { return; } @@ -30,7 +30,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.on("agent.permission_requested", async (event, context) => { + server.addHook("agent.permission.before", async (event, context) => { const command = shellCommand(event.request); if (command === null) { return; @@ -56,7 +56,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { return { ...request, env: { @@ -66,7 +66,7 @@ export default function contribute(server: PluginServerContext) { }; }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider === "codex") { return { ...request, @@ -82,7 +82,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { return { ...request, env: { diff --git a/plugin-examples/lifecycle-actions/paseo-plugin.json b/plugin-examples/lifecycle-actions/paseo-plugin.json index fe578fae6d..df289d0aa2 100644 --- a/plugin-examples/lifecycle-actions/paseo-plugin.json +++ b/plugin-examples/lifecycle-actions/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-actions", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/lifecycle-logger/README.md b/plugin-examples/lifecycle-logger/README.md index d6c00b8032..60259d34c7 100644 --- a/plugin-examples/lifecycle-logger/README.md +++ b/plugin-examples/lifecycle-logger/README.md @@ -1,6 +1,6 @@ # Lifecycle logger -Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation, +Registers agent, workspace, and worktree hooks and logs their data as JSON. It observes agent creation, session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return the request unchanged. Environment values are redacted; prompts and timeline content are logged. diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 0f5fd4ed95..e87be2c2f6 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -2,55 +2,59 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { logHook } from "./server/log"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { - logHook("before workspace.create", { request }); + server.addHook("workspace.create.before", async ({ request }) => { + logHook("workspace.create.before", { request }); return request; }); - server.on("workspace.created", (event) => { - logHook("workspace.created", event); + server.addHook("workspace.create.after", async (event) => { + logHook("workspace.create.after", event); }); - server.on("workspace.archived", (event) => { - logHook("workspace.archived", event); + server.addHook("workspace.archive.after", async (event) => { + logHook("workspace.archive.after", event); }); - server.before("agent.create", ({ request }) => { - logHook("before agent.create", { request }); + server.addHook("worktree.create.after", async (event) => { + logHook("worktree.create.after", event); + }); + + server.addHook("agent.create.before", async ({ request }) => { + logHook("agent.create.before", { request }); return request; }); - server.before("agent.session_open", ({ request }) => { - logHook("before agent.session_open", { request }); + server.addHook("agent.open.before", async ({ request }) => { + logHook("agent.open.before", { request }); return request; }); - server.on("agent.created", (event) => { - logHook("agent.created", event); + server.addHook("agent.create.after", async (event) => { + logHook("agent.create.after", event); }); - server.on("agent.closed", (event) => { - logHook("agent.closed", event); + server.addHook("agent.close.after", async (event) => { + logHook("agent.close.after", event); }); - server.on("agent.turn_started", (event) => { - logHook("agent.turn_started", event); + server.addHook("agent.turn.before", async (event) => { + logHook("agent.turn.before", event); }); - server.on("agent.turn_ended", (event) => { - logHook("agent.turn_ended", event); + server.addHook("agent.turn.after", async (event) => { + logHook("agent.turn.after", event); }); - server.on("agent.permission_requested", (event) => { - logHook("agent.permission_requested", event); + server.addHook("agent.permission.before", async (event) => { + logHook("agent.permission.before", event); }); - server.on("agent.permission_resolved", (event) => { - logHook("agent.permission_resolved", event); + server.addHook("agent.permission.after", async (event) => { + logHook("agent.permission.after", event); }); - server.on("agent.archived", (event) => { - logHook("agent.archived", event); + server.addHook("agent.archive.after", async (event) => { + logHook("agent.archive.after", event); }); return () => {}; diff --git a/plugin-examples/lifecycle-logger/paseo-plugin.json b/plugin-examples/lifecycle-logger/paseo-plugin.json index 8385fbf14c..2c964d4622 100644 --- a/plugin-examples/lifecycle-logger/paseo-plugin.json +++ b/plugin-examples/lifecycle-logger/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-logger", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/worktree-placement/README.md b/plugin-examples/worktree-placement/README.md new file mode 100644 index 0000000000..8d88d8c627 --- /dev/null +++ b/plugin-examples/worktree-placement/README.md @@ -0,0 +1,36 @@ +# Worktree placement + +Place worktrees next to their source checkout, using the repository directory name and branch. +For `/home/me/src/paseo` on branch `feature/x`, the new worktree is +`/home/me/src/paseo.feature-x`. Slashes in branch names become hyphens. + +Install on Paseo 0.14.0-beta.1 or newer with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/worktree-placement +``` + +The plugin is one file, `index.server.ts`: + +```ts +import { basename, dirname, join } from "node:path"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), + })); +} +``` + +Paseo validates the returned path and refuses an existing one. It creates, lists, restores, +and removes the worktree through the normal workspace workflow. Restoring uses the recorded +path without running the hook. Git can choose a unique branch name after this hook runs; +the path retains the branch name received by the hook. + +See [worktree.create.before](../../public-docs/plugins/hooks.md#worktreecreatebefore) +for the request fields and validation rules. diff --git a/plugin-examples/worktree-placement/index.server.ts b/plugin-examples/worktree-placement/index.server.ts new file mode 100644 index 0000000000..67509a000d --- /dev/null +++ b/plugin-examples/worktree-placement/index.server.ts @@ -0,0 +1,12 @@ +import { basename, dirname, join } from "node:path"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), + })); +} diff --git a/plugin-examples/worktree-placement/paseo-plugin.json b/plugin-examples/worktree-placement/paseo-plugin.json new file mode 100644 index 0000000000..ab7f7a20e6 --- /dev/null +++ b/plugin-examples/worktree-placement/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "worktree-placement", + "requirements": { "paseo": ">=0.14.0-beta.1" } +} diff --git a/plugins/worktree-include/LICENSE b/plugins/worktree-include/LICENSE new file mode 100644 index 0000000000..60cf520f1d --- /dev/null +++ b/plugins/worktree-include/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Carlos Precioso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/worktree-include/index.server.ts b/plugins/worktree-include/index.server.ts new file mode 100644 index 0000000000..9055c1677d --- /dev/null +++ b/plugins/worktree-include/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { copyWorktreeIncludes } from "./server/copy.js"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.after", async ({ worktree }) => { + await copyWorktreeIncludes(worktree.path, worktree.sourceCheckout); + }); +} diff --git a/plugins/worktree-include/paseo-plugin.json b/plugins/worktree-include/paseo-plugin.json new file mode 100644 index 0000000000..711476b70f --- /dev/null +++ b/plugins/worktree-include/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "worktree-include", "requirements": { "paseo": ">=0.14.0-beta.1" } } diff --git a/plugins/worktree-include/server/copy.ts b/plugins/worktree-include/server/copy.ts new file mode 100644 index 0000000000..73a3ba63f3 --- /dev/null +++ b/plugins/worktree-include/server/copy.ts @@ -0,0 +1,71 @@ +// Derived from cprecioso/paseo-worktreeinclude (MIT); see ./LICENSE in the plugin root. +// Copyright (c) 2026 Carlos Precioso. +import { execFile } from "node:child_process"; +import { constants, existsSync } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const INCLUDE_FILE = ".worktreeinclude"; + +export type CopyResult = + | { kind: "no-include-file" } + | { kind: "copied"; copied: string[]; skipped: string[] }; + +async function git(cwd: string, args: string[], input?: string): Promise { + const child = exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 }); + if (input !== undefined) { + child.child.stdin?.end(input); + } + const { stdout } = await child; + return stdout; +} + +async function gitAllowExit1(cwd: string, args: string[], input: string): Promise { + try { + return await git(cwd, args, input); + } catch (error) { + // `git check-ignore` exits 1 when nothing matched. + if ((error as { code?: number }).code === 1) return ""; + throw error; + } +} + +const splitNul = (s: string) => s.split("\0").filter(Boolean); + +// Copy files that match .worktreeinclude (gitignore syntax) +// AND are gitignored, so tracked files are never touched. +export async function copyWorktreeIncludes(worktree: string, source: string): Promise { + if (!existsSync(join(source, INCLUDE_FILE))) return { kind: "no-include-file" }; + + const matches = splitNul( + await git(source, [ + "ls-files", + "--others", + "--ignored", + `--exclude-from=${INCLUDE_FILE}`, + "-z", + ]), + ); + if (matches.length === 0) return { kind: "copied", copied: [], skipped: [] }; + + const ignored = splitNul( + await gitAllowExit1(source, ["check-ignore", "--stdin", "-z"], matches.join("\0") + "\0"), + ); + + const copied: string[] = []; + const skipped: string[] = []; + for (const file of ignored) { + const dest = join(worktree, file); + if (existsSync(dest)) { + skipped.push(file); + continue; + } + await mkdir(dirname(dest), { recursive: true }); + // Clone (APFS/btrfs copy-on-write) when supported, else fall back to a regular copy. + await copyFile(join(source, file), dest, constants.COPYFILE_FICLONE); + copied.push(file); + } + return { kind: "copied", copied, skipped }; +} diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md new file mode 100644 index 0000000000..b535821e62 --- /dev/null +++ b/public-docs/plugins/hooks.md @@ -0,0 +1,659 @@ +--- +title: Plugin hooks +description: Register hooks to change requests or observe agent and workspace operations. +nav: Hooks +order: 48 +category: Plugins +--- + +# Plugin hooks + +Use `server.addHook(name, handler)` in `index.server.ts`. Hooks run while the plugin is enabled, even with no app connected. `addHook` is available from Paseo 0.14; set `requirements.paseo` to the release you build against. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## Naming and timing + +Names follow `subject.operation.position`: the subject, the operation, then `before` or `after`. The name identifies a lifecycle position. Whether Paseo waits is a per-hook property shown in the table below. `workspace` covers every workspace; `worktree` covers only git worktrees. + +- Waiting hooks finish before Paseo continues. Throwing, returning invalid data, or exceeding 30 seconds fails the operation with `Plugin failed: …`. Request-changing hooks receive `{ request }` and may return a complete replacement request or `undefined`. +- Notifications receive the event object and return `Promise`. Paseo does not wait; errors are logged against the plugin and the operation continues. `agent.turn.before` and `agent.permission.before` are notifications fired when a turn starts or a permission becomes pending. + +All handlers must be async. Turn completion includes success, failure, and cancellation; inspect `outcome`. Permission completion includes every resolution; inspect `resolution`. + +Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action. + +`worktree.create.after` runs before the workspace is registered. It precedes `workspace.create.after`, and both precede the first agent's `agent.create.before` / `agent.open.before`. + +### Return early from a waiting hook + +Start detached work and return when the operation should continue immediately: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo only sees the returned promise, so detached work gets no error reporting and no cancellation `signal`. + +## Hooks + +| Hook | Waits | Payload | Handler may change | +| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- | +| `workspace.create.before` | yes | `{ request }` | Entire create request | +| `workspace.create.after` | no | `{ workspace }` | | +| `workspace.archive.after` | no | `{ workspace }` | | +| `worktree.create.before` | yes | `{ request }` | Creation options and destination | +| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation | +| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` | +| `agent.create.after` | no | `{ agent }` | | +| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` | +| `agent.close.after` | no | `{ agent }` | | +| `agent.archive.after` | no | `{ agent, archivedAt }` | | +| `agent.turn.before` | no | `{ agent, turnId }` | | +| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | no | `{ agent, request }` | | +| `agent.permission.after` | no | `{ agent, requestId, resolution }` | | + +## Shared payloads + +```ts +interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; +``` + +| Field | Shape / meaning | +| ------------ | -------------------------------------------------------------------------------------- | +| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens | +| `timeline` | Deprecated, always `[]`; fetch history with the SDK. | +| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | +| `resolution` | SDK `AgentPermissionResponse` | +| `archivedAt` | Timestamp string | + +## workspace.create.before + +Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected. + +### Choose workspace isolation + +```ts +server.addHook("workspace.create.before", async ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces +and directory lookup/import operations are unaffected. + +## workspace.create.after + +Fires after the workspace record is created and its directory is available. Receives `{ workspace }`. + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after + +Fires after archive state is saved. Receives `{ workspace }`; worktree cleanup can still be pending. + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.before {#worktreecreatebefore} + +Choose the location and creation options for a new worktree. Paseo awaits this hook before `git worktree add`, on creation from the app, CLI, MCP, and agent creation. Receives `{ request }`; return a complete replacement request or `undefined` to keep it. Throwing, invalid returned data, or a 30-second timeout aborts creation with the plugin and hook named in the error. + +```ts +import { basename, dirname, join } from "node:path"; + +server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), +})); +``` + +For source `/home/me/src/paseo` and branch `feature/x`, this creates `/home/me/src/paseo.feature-x`. See the complete [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement). + +| Request field | Meaning | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kind` | `branch-off`, `checkout-branch`, `checkout-change-request`, or `checkout-github-pr` | +| `branch` | Requested local branch name | +| `baseRef` | Starting ref for branch-off; comparison base for change requests; absent for `checkout-branch`. To branch off a ref, return `kind: "branch-off"`. | +| `slug` | Normalized worktree name | +| `path` | Absolute destination; initially the default location with any existing-path suffix resolved | +| `repoRoot` | Read-only main repository root | +| `sourceCheckout` | Read-only source checkout root, including when creation starts from another worktree | +| `forge`, `changeRequestNumber`, `headRef` | Change-request checkout identity; GitHub legacy checkouts use `githubPrNumber` instead of `forge` and `changeRequestNumber` | +| `headRepositoryOwner`, `headRepository` | Optional source repository facts; `headRepository` is present only on change-request checkouts | +| `checkoutRefs` | Optional fetch sources: `{ remoteName?: string, remoteRef: string }[]` | +| `pushRemoteUrl`, `trackOriginHead` | Optional push destination and tracking policy | + +Once `path` is returned, `slug` only feeds branch-off naming; the CLI looks up worktrees by the basename of their path. + +Every field except `repoRoot` and `sourceCheckout` is transformable. Return all required facts for the chosen `kind`. Paseo resolves the Git source plan after this hook, so handlers can change the branch and base before fetching refs, while Git can still choose a unique final branch name afterward. + +Paseo resolves symlinked parents and validates the returned path. It must be absolute, must not exist, and must not be the filesystem root, your home directory, the repository root, inside a `.git` directory, or an ancestor of the repository. An existing destination fails creation; Paseo never adds a suffix to a plugin-chosen path. The plugin must leave directory creation to Paseo. + +Ordering: `worktree.create.before` → `git worktree add` → `worktree.create.after` → workspace registration. Restoring a missing worktree recreates it at its recorded path without running either worktree creation hook. + +## worktree.create.after + +Paseo waits for this hook after a new git worktree exists and before workspace registration, setup, terminals, or agents. Receives `{ worktree: { path, branch, sourceCheckout } }`: the absolute worktree path, branch name, and source checkout directory. There is no workspace id yet. Return nothing; write files directly into the worktree. Throwing or exceeding 30 seconds aborts creation and removes the new worktree. + +This covers branch-off, branch checkout, and PR checkout creation. Restoring a missing archived worktree uses a separate recovery path and does not fire this hook. + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before + +Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation. + +**`agent.create.before.config`** uses `AgentSessionConfig`: + +| Fields | Constraint | +| --------------------------------------------- | -------------------------------------------------------------------------- | +| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | +| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | +| `title`, `systemPrompt` | Agent configuration | +| `providerOptions` | Opaque provider-specific options | +| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | +| `cwd` | Cannot change | + +### Change saved configuration + +Add this callback inside `contribute(server)` to set the saved options for new Codex agents: + +```ts +server.addHook("agent.create.before", async ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + }, + }; +}); +``` + +| Input | Result | +| ------------------------------------------- | ----------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | + +The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options +override its mode presets. + +## agent.create.after + +Ordinary creation finishes. Receives `{ agent }`; import and resume do not fire it. + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before + +Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open. + +**`agent.open.before` request example:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| Field | Values | +| ------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | String or `null` | +| `title` | Read-only string or `null`; creation title on create, stored title on resume. | +| `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | +| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | +| `reason` | `create`, `resume`, `refresh`, `import` | +| `purpose` | `interactive`, `history` | +| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | + +### Inject environment variables on every session opening + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +Session-open overrides are re-derived on every open. `agent.create.before.env` applies only to the +initial launch and is not saved in the agent's configuration. + +### Inject MCP servers + +Use `agent.create.before.config.mcpServers` when the server should become part of the agent's saved +configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing +the placeholder URL with your MCP endpoint: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. +The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never +contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, +or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' +final maps in plugin-ID order. + +A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server +fails the session open and names the server and both sources. A provider without MCP support fails +with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore +these servers. Install, disable, and reload changes reach existing agents through the +[session reopen behavior](#attach-tools-to-agents). + +### Attach tools to agents + +Define a tool in `index.server.ts` and attach it in `server.addHook("agent.open.before", async handler)`. +This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +Set the label when creating an agent with the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +Select agents using any of the session-open request fields: + +| Select by | Condition in the callback | +| ---------------- | -------------------------------------------- | +| Label | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| Workspace | `request.workspaceId === "workspace-456"` | +| Individual agent | `request.agentId === chosenAgentId` | + +`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier +callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves +the request. Tools are ignored for `purpose: "history"`. + +Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from +`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| Execution context | Value | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | +| `paseo` | SDK connected to this daemon | +| `signal` | Abort signal for this tool call; pass it to file operations or external requests | + +`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool +metadata and call inputs/results cross the process boundary. Paseo validates input before calling +`execute`. Invalid input returns schema issues to the agent. A thrown error becomes +`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its +transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate +from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. + +Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is +omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. + +Paseo delivers attached tools through its agent tool transport, using MCP or native registration +as the provider supports. The same definition and callback work with either. Attached tools obey +the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. + +Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo +tool or another plugin's tool on the same agent fails the session open and names both owners. +A duplicate name within one plugin also fails the session open. Agents the callback does not +target are unaffected. + +| Change | When the agent receives it | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start, stop, or reload a plugin declaring `agent.open.before` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | +| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | +| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | +| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | + +If an automatic reopen fails, the triggering prompt fails with that error. See the +[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). + +## agent.close.after + +A live agent runtime closes. Receives `{ agent }`. + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after + +Archive state is saved. Receives `{ agent, archivedAt }`; runtime cleanup can still be pending. + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before + +A live turn starts. Receives `{ agent, turnId }`. History replay does not fire it. + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after + +A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below. + +### Send a follow-up when a turn ends + +Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) +into your plugin. The helper imports types from `@getpaseo/protocol/agent-types`; add +`@getpaseo/protocol` at the version matching your daemon to your development dependencies +and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +The handler fetches only the last 20 timeline items. Output before that page is not inspected. +Use `cursor` and `direction` to page further when your plugin needs more history. + +This sends a new message with the existing SDK. Persistent matches keep sending follow-ups; +add limits or delays in your plugin when needed. Attachments and tool effects are not replayed. + +## agent.permission.before + +A permission or question becomes pending. Receives `{ agent, request }`; `request` is an `AgentPermissionRequest`. Send the answer through the SDK. + +### Answer a permission request + +Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Recursive deletion is blocked." }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + return () => {}; +} +``` + +| Request | Result | +| ------------------------ | ------------------------- | +| `rm -rf build` | Declined | +| `git status` | Approved | +| Other command or request | Left pending for the user | +| Already-resolved request | SDK response fails | + +The regex is an example policy, not a shell parser. Permission requests can also be questions, +plans, and mode changes; requesting permission does not end the turn. + +## agent.permission.after + +A pending request is answered or cleared. Receives `{ agent, requestId, resolution }`; `resolution` is an `AgentPermissionResponse`. + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks. + +## Ordering and returned values + +```text +Creation request + → agent.create.before hooks (plugin-ID order; registration order within each plugin) + → resolve defaults and validate provider configuration + → derive launch configuration with Paseo runtime tools and daemon prompt + → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| Callback returns | Next callback receives | +| ------------------------------------------------------- | --------------------------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced | +| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced | +| `undefined` | Unchanged request | +| Throws or returns invalid data | Operation fails; later callbacks do not run | + +There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` +accumulate only across callbacks within the same plugin. Returning a replacement map or array +replaces that scope's prior value. + +## Context and cleanup + +| Contract | Behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `context.paseo` | Existing SDK connected to this daemon | +| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests | +| Input data | Detached snapshot; change state through returned requests or SDK commands | +| Registration result | Idempotent remover, e.g. `const remove = server.addHook(...); remove();` | +| Reload, disable, removal, shutdown | Remaining registrations removed | +| Unknown hook name | Registration fails | +| Hook timeout | 30 seconds; aborts the signal. A waiting hook fails the pending operation; a notification handler logs an error. | +| Event-handler error | Logged against plugin; original operation continues | +| Event delivery | Live, best effort; no replay, persistence, or automatic retry | +| Event concurrency | Different events may overlap; callback completion order is not guaranteed | + +## Complete examples + +| Plugin | Includes | +| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent, workspace, and worktree hooks; JSON logs with environment values redacted | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | + +Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. + +## Renamed hooks + +The old registration methods are deprecated for removal after 2027-04-30. Existing plugins keep their behavior. Mixed old and new registrations within a plugin run in registration order. `addHook` accepts only the new names. + +| Old registration | New registration | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md new file mode 100644 index 0000000000..eecf370a29 --- /dev/null +++ b/public-docs/plugins/hooks.zh-CN.md @@ -0,0 +1,641 @@ +--- +title: 插件钩子 +description: 为 Agent 和工作区操作注册钩子,修改请求或接收通知。 +nav: 钩子 +--- + +# 插件钩子 {#plugin-hooks} + +在 `index.server.ts` 中使用 `server.addHook(name, handler)`。钩子在插件启用时运行,即使没有连接的应用。`addHook` 从 Paseo 0.14 起可用;将 `requirements.paseo` 设置为你开发插件时使用的版本。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## 命名与等待 {#naming-and-timing} + +名称遵循 `subject.operation.position`:主体、操作、`before` 或 `after`。名称标识生命周期中的位置;Paseo 是否等待由每个钩子决定,见下表。`workspace` 包括所有工作区,`worktree` 仅包括 git worktree。 + +- 等待型钩子结束后 Paseo 才继续。抛出错误、返回无效数据或超过 30 秒会让操作失败,错误包含 `Plugin failed: …`。修改请求的钩子收到 `{ request }`,可以返回完整的替换请求或 `undefined`。 +- 通知收到事件对象,返回 `Promise`。Paseo 不等待;错误会记录在插件名下,原操作继续。`agent.turn.before` 和 `agent.permission.before` 分别在轮次开始或权限进入待处理状态时发出通知。 + +所有处理函数都必须为异步函数。轮次完成包括成功、失败和取消,请查看 `outcome`。权限完成包括所有处理结果,请查看 `resolution`。 + +创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。 + +`worktree.create.after` 在工作区注册前运行,随后是 `workspace.create.after`,两者都早于第一个 Agent 的 `agent.create.before` / `agent.open.before`。 + +### 从等待型钩子提前返回 {#return-early-from-a-waiting-hook} + +如果操作应立即继续,可以启动独立任务并返回: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没有取消 `signal`。 + +## 钩子一览 {#hooks} + +| 钩子 | 等待 | 载荷 | 可修改 | +| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- | +| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 | +| `workspace.create.after` | 否 | `{ workspace }` | | +| `workspace.archive.after` | 否 | `{ workspace }` | | +| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 | +| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 | +| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` | +| `agent.create.after` | 否 | `{ agent }` | | +| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `agent.close.after` | 否 | `{ agent }` | | +| `agent.archive.after` | 否 | `{ agent, archivedAt }` | | +| `agent.turn.before` | 否 | `{ agent, turnId }` | | +| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | 否 | `{ agent, request }` | | +| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | | + +## 共享载荷 {#shared-payloads} + +```ts +interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; +``` + +| 字段 | 结构 / 含义 | +| ------------ | ---------------------------------------------------------------------------------------- | +| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | +| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 | +| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | +| `resolution` | SDK 的 `AgentPermissionResponse` | +| `archivedAt` | 时间戳字符串 | + +## workspace.create.before {#workspacecreatebefore} + +收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。 + +### 选择工作区隔离方式 {#choose-workspace-isolation} + +```ts +server.addHook("workspace.create.before", async ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 + +## workspace.create.after {#workspacecreateafter} + +工作区记录创建且目录可用后触发,收到 `{ workspace }`。 + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after {#workspacearchiveafter} + +归档状态保存后触发,收到 `{ workspace }`;worktree 清理可能尚未完成。 + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.before {#worktreecreatebefore} + +为新 worktree 选择路径和创建选项。通过应用、CLI、MCP 或创建 Agent 新建 worktree 时,Paseo 在 `git worktree add` 之前等待此钩子。钩子收到 `{ request }`,返回完整的替换请求,或返回 `undefined` 保持原值。抛出错误、返回无效数据或超过 30 秒会终止创建,错误包含插件和钩子名称。 + +```ts +import { basename, dirname, join } from "node:path"; + +server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), +})); +``` + +源检出目录为 `/home/me/src/paseo`、分支为 `feature/x` 时,目标为 `/home/me/src/paseo.feature-x`。完整插件见 [worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement)。 + +| 请求字段 | 含义 | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `kind` | `branch-off`、`checkout-branch`、`checkout-change-request` 或 `checkout-github-pr` | +| `branch` | 请求的本地分支名称 | +| `baseRef` | branch-off 的起始引用;变更请求的比较基准;`checkout-branch` 不含此字段。要从引用创建分支,返回 `kind: "branch-off"`。 | +| `slug` | 规范化的 worktree 名称 | +| `path` | 绝对目标路径,初始值为已处理现有路径后缀的默认位置 | +| `repoRoot` | 只读,主仓库根目录 | +| `sourceCheckout` | 只读,源检出根目录,也支持从另一个 worktree 发起创建 | +| `forge`、`changeRequestNumber`、`headRef` | 变更请求检出标识;旧版 GitHub 检出用 `githubPrNumber` 代替 `forge` 和 `changeRequestNumber` | +| `headRepositoryOwner`、`headRepository` | 可选的源仓库信息;`headRepository` 仅用于变更请求检出 | +| `checkoutRefs` | 可选的获取源:`{ remoteName?: string, remoteRef: string }[]` | +| `pushRemoteUrl`、`trackOriginHead` | 可选的推送目标和跟踪策略 | + +返回 `path` 后,`slug` 仅用于 branch-off 分支命名;CLI 使用路径的最后一级目录名查找 worktree。 + +除 `repoRoot` 和 `sourceCheckout` 外,所有字段都可以修改。返回所选 `kind` 的全部必填字段。Paseo 在钩子之后解析 Git 源计划,因此钩子可以在获取引用之前修改分支和基准,而 Git 之后仍可能选择唯一的最终分支名称。 + +Paseo 解析父目录中的符号链接并验证返回的路径。路径必须为绝对路径且不存在,不能是文件系统根目录、用户主目录、仓库根目录、`.git` 目录内的路径或仓库的祖先目录。目标已存在时创建失败,Paseo 不会为插件选择的路径添加后缀。插件应让 Paseo 创建目录。 + +顺序:`worktree.create.before` → `git worktree add` → `worktree.create.after` → 注册工作区。恢复缺失的 worktree 时使用记录的原路径,不运行这两个创建钩子。 + +## worktree.create.after {#worktreecreateafter} + +新的 git worktree 在磁盘上创建后,Paseo 会等待此钩子,然后才注册工作区、运行 setup、打开终端或启动 Agent。收到 `{ worktree: { path, branch, sourceCheckout } }`,分别是 worktree 绝对路径、分支名、来源 checkout 目录。此时还没有工作区 id。无需返回数据,直接写入 worktree 即可。抛出错误或超过 30 秒会中止创建并删除新 worktree。 + +此钩子覆盖新建分支、检出分支和检出 PR。恢复缺失的已归档 worktree 使用独立路径,不触发此钩子。 + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before {#agentcreatebefore} + +收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。 + +**`agent.create.before.config`** 使用 `AgentSessionConfig`: + +| 字段 | 约束 | +| --------------------------------------------- | -------------------------------------------------------- | +| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | +| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | +| `title`, `systemPrompt` | Agent 配置 | +| `providerOptions` | 不透明的、特定于 Provider 的选项 | +| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | +| `cwd` | 不能修改 | + +### 修改已保存的配置 {#change-saved-configuration} + +在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: + +```ts +server.addHook("agent.create.before", async ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + }, + }; +}); +``` + +| 输入 | 结果 | +| ------------------------------------------- | ------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | 由展开运算保留 | + +所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 + +## agent.create.after {#agentcreateafter} + +常规创建完成后触发,收到 `{ agent }`;导入和恢复不会触发。 + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before {#agentopenbefore} + +收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。 + +**`agent.open.before` 请求示例:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| 字段 | 取值 | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `workspaceId` | 字符串或 `null` | +| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | +| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | +| `reason` | `create`、`resume`、`refresh`、`import` | +| `purpose` | `interactive`、`history` | +| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | + +### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.before.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 + +### 注入 MCP 服务器 {#inject-mcp-servers} + +需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.before.config.mcpServers`。 +插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 +每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 +替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 +Paseo 按插件 ID 顺序合并各插件的最终映射。 + +名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 +不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 +`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent +通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 + +### 为 Agent 附加工具 {#attach-tools-to-agents} + +在 `index.server.ts` 中定义工具,并在 `server.addHook("agent.open.before", async handler)` 中附加它。 +此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +通过 SDK 创建 Agent 时设置标签: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +使用会话打开请求中的字段选择 Agent: + +| 选择依据 | 回调中的条件 | +| ---------- | -------------------------------------------- | +| 标签 | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| 工作区 | `request.workspaceId === "workspace-456"` | +| 单个 Agent | `request.agentId === chosenAgentId` | + +每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; +替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 + +从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` +和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| 执行 context | 值 | +| ------------ | --------------------------------------------------------------------------------------------------- | +| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | +| `paseo` | 已连接到此 daemon 的 SDK | +| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | + +`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 +输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 +Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 +其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 +关闭或重新打开 Agent 会话会移除旧定义。 + +在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, +Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 + +Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 +同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 +只有该 Agent 能看到这些工具。 + +名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo +工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 +会话打开失败。回调未选中的 Agent 不受影响。 + +| 变更 | Agent 何时收到变更 | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 启动、停止或重新加载声明了 `agent.open.before` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | +| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | +| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | +| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | + +如果自动重新打开失败,触发它的提示词会返回该错误。参阅 +[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 + +## agent.close.after {#agentcloseafter} + +运行中的 Agent 实例关闭后触发,收到 `{ agent }`。 + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after {#agentarchiveafter} + +归档状态保存后触发,收到 `{ agent, archivedAt }`;运行时清理可能尚未完成。 + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before {#agentturnbefore} + +实时轮次开始后触发,收到 `{ agent, turnId }`。历史重放不会触发。 + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after {#agentturnafter} + +实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。 + +### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} + +把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与 daemon 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 + +这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 + +## agent.permission.before {#agentpermissionbefore} + +权限或问题进入待处理状态后触发,收到 `{ agent, request }`;`request` 为 `AgentPermissionRequest`。通过 SDK 回应。 + +### 回应权限请求 {#answer-a-permission-request} + +使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Recursive deletion is blocked." }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + return () => {}; +} +``` + +| 请求 | 结果 | +| ---------------- | -------------------- | +| `rm -rf build` | 拒绝 | +| `git status` | 批准 | +| 其他命令或请求 | 保持待处理,留给用户 | +| 已经处理过的请求 | SDK 响应失败 | + +这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 + +## agent.permission.after {#agentpermissionafter} + +待处理请求得到回应或清除后触发,收到 `{ agent, requestId, resolution }`;`resolution` 为 `AgentPermissionResponse`。 + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。 + +## 顺序与返回值 {#ordering-and-returned-values} + +```text +Creation request + → agent.create.before hooks (plugin-ID order; registration order within each plugin) + → resolve defaults and validate provider configuration + → derive launch configuration with Paseo runtime tools and daemon prompt + → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| 回调返回 | 下一个回调收到 | +| ------------------------------------------------------- | --------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | +| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | +| `undefined` | 未改变的请求 | +| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | + +没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 +返回替换的映射或数组会替换该范围内之前的值。 + +## context 与清理 {#context-and-cleanup} + +| 契约 | 行为 | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `context.paseo` | 已连接到此 daemon 的现有 SDK | +| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | +| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | +| 注册结果 | 幂等的移除函数,例如 `const remove = server.addHook(...); remove();` | +| 重新加载、停用、移除、关闭 | 移除剩余的注册 | +| 未知的钩子名称 | 注册失败 | +| 钩子超时 | 30 秒;会中止 signal。等待型钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | +| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | +| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | +| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | + +## 完整示例 {#complete-examples} + +| 插件 | 包含 | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent、工作区和 worktree 钩子;隐去环境变量值的 JSON 日志 | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | + +用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 + +## 已重命名的钩子 {#renamed-hooks} + +旧注册方法已弃用,会在 2027-04-30 之后移除。现有插件继续按原来的方式运行。同一插件中混合使用新旧注册方法时,处理函数按注册顺序运行。`addHook` 只接受新名称。 + +| 旧注册方式 | 新注册方式 | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 439892edb8..a5565b71c7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2,7 +2,7 @@ title: Plugin reference description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands. nav: Reference -order: 48 +order: 49 category: Plugins --- @@ -628,492 +628,11 @@ export default function contribute(client: PluginClientContext) { } ``` -Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown. +Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Reload, disable, and removal dispose the affected installation. Stopping a server plugin also closes its daemon session and stops its subprocess. Client installations follow [Hosts and lifecycle](#hosts-and-lifecycle), including retention during a temporary disconnect. ## Lifecycle hooks -In `index.server.ts`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", (event) => { - console.log(event.agent.id, event.outcome); - }); - - return () => {}; -} -``` - -| Register | Callback receives | Return | -| ------------------------------- | ---------------------------------- | ------------------------------------------------------------ | -| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` or `Promise` | -| `server.before(name, callback)` | `({ request }, { paseo, signal })` | Modified request, or `undefined` to keep it; async supported | - -Hooks run on the daemon while the plugin is enabled, even with no app connected. - -`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain -after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`, -and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's -lifecycle without being saved in the agent record. - -### Change saved configuration - -Add this callback inside `contribute(server)` to set the saved options for new Codex agents: - -```ts -server.before("agent.create", ({ request }) => { - if (request.config.provider !== "codex") { - return request; - } - - return { - ...request, - config: { - ...request.config, - providerOptions: { - ...request.config.providerOptions, - sandbox_mode: "workspace-write", - approval_policy: "on-request", - }, - }, - }; -}); -``` - -| Input | Result | -| ------------------------------------------- | ----------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | Preserved by the spread | - -The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options -override its mode presets. - -### Inject MCP servers - -Use `agent.create.config.mcpServers` when the server should become part of the agent's saved -configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing -the placeholder URL with your MCP endpoint: - -```ts -server.before("agent.session_open", ({ request }) => { - if (request.provider !== "codex") return; - - return { - ...request, - mcpServers: { - ...request.mcpServers, - company: { type: "http", url: "https://tools.example.com/mcp" }, - }, - }; -}); -``` - -`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. -The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never -contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, -or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' -final maps in plugin-ID order. - -A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server -fails the session open and names the server and both sources. A provider without MCP support fails -with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore -these servers. Install, disable, and reload changes reach existing agents through the -[session reopen behavior](#attach-tools-to-agents). - -### Inject environment variables on every session opening - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -Session-open overrides are re-derived on every open. `agent.create.env` applies only to the -initial launch and is not saved in the agent's configuration. - -### Attach tools to agents - -Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`. -This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: - -```ts -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; - -const appendWorkspaceNote = defineAgentTool({ - name: "append_workspace_note", - description: "Append a note to AGENT_NOTES.md in this agent's working directory.", - input: z.object({ note: z.string().min(1).describe("Note to append") }), - async execute({ note }, { agent, signal }) { - const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); - await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); - return { - text: `Saved note to ${notePath}`, - structuredContent: { path: notePath }, - }; - }, -}); - -export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { - if (request.labels["notes/enabled"] !== "true") return; - return { ...request, tools: [...request.tools, appendWorkspaceNote] }; - }); - return () => {}; -} -``` - -Set the label when creating an agent with the SDK: - -```ts -const agent = await paseo.agents.create({ - config: { provider: "codex/gpt-5.4" }, - cwd: "/projects/shop", - labels: { "notes/enabled": "true" }, -}); -``` - -Select agents using any of the session-open request fields: - -| Select by | Condition in the callback | -| ---------------- | -------------------------------------------- | -| Label | `request.labels["notes/enabled"] === "true"` | -| Provider | `request.provider === "codex"` | -| Workspace | `request.workspaceId === "workspace-456"` | -| Individual agent | `request.agentId === chosenAgentId` | - -`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier -callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves -the request. Tools are ignored for `purpose: "history"`. - -Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from -`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: - -```ts -declare function defineAgentTool(tool: { - name: string; - title?: string; - description: string; - input: Input; - execute( - input: z.output, - context: AgentToolContext, - ): AgentToolResult | Promise; -}): AgentToolDefinition; - -interface AgentToolResult { - text?: string; - structuredContent?: unknown; - isError?: boolean; -} -``` - -| Execution context | Value | -| ----------------- | --------------------------------------------------------------------------------------------------- | -| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | -| `paseo` | SDK connected to this daemon | -| `signal` | Abort signal for this tool call; pass it to file operations or external requests | - -`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool -metadata and call inputs/results cross the process boundary. Paseo validates input before calling -`execute`. Invalid input returns schema issues to the agent. A thrown error becomes -`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its -transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate -from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. - -Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is -omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. - -Paseo delivers attached tools through its agent tool transport, using MCP or native registration -as the provider supports. The same definition and callback work with either. Attached tools obey -the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. - -Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo -tool or another plugin's tool on the same agent fails the session open and names both owners. -A duplicate name within one plugin also fails the session open. Agents the callback does not -target are unaffected. - -| Change | When the agent receives it | -| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | -| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | -| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | -| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | - -If an automatic reopen fails, the triggering prompt fails with that error. See the -[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). - -### Choose workspace isolation - -```ts -server.before("workspace.create", ({ request }) => { - if (request.source.kind !== "directory") { - return request; - } - - return { - ...request, - source: { - kind: "worktree", - cwd: request.source.path, - action: "branch-off", - }, - }; -}); -``` - -**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces -and directory lookup/import operations are unaffected. - -### Send a follow-up when a turn ends - -Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) -into your plugin. The helper imports types from `@getpaseo/protocol/agent-types`; add -`@getpaseo/protocol` at the same version as your plugin SDK to your development dependencies -and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { latestOutputText } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", async (event, context) => { - if (event.outcome.kind === "canceled") { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -The handler fetches only the last 20 timeline items. Output before that page is not inspected. -Use `cursor` and `direction` to page further when your plugin needs more history. - -This sends a new message with the existing SDK. Persistent matches keep sending follow-ups; -add limits or delays in your plugin when needed. Attachments and tool effects are not replayed. - -### Answer a permission request - -Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { shellCommand } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.permission_requested", async (event, context) => { - const command = shellCommand(event.request); - if (command === null) { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - if (/\brm\s+-rf\b/.test(command)) { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "deny", message: "Recursive deletion is blocked." }, - }); - return; - } - - if (command.trim() === "git status") { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "allow" }, - }); - } - }); - - return () => {}; -} -``` - -| Request | Result | -| ------------------------ | ------------------------- | -| `rm -rf build` | Declined | -| `git status` | Approved | -| Other command or request | Left pending for the user | -| Already-resolved request | SDK response fails | - -The regex is an example policy, not a shell parser. Permission requests can also be questions, -plans, and mode changes; requesting permission does not end the turn. - -### Events - -| Name | Event fields | Trigger | -| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------- | -| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume | -| `agent.closed` | `agent` | A live agent runtime closes | -| `agent.turn_started` | `agent`, `turnId` | Live turn starts | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, deprecated `timeline: []` | Live turn completes, fails, or is canceled | -| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending | -| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared | -| `agent.archived` | `agent`, `archivedAt` | Archive state is saved | -| `workspace.created` | `workspace` | Record created; directory available | -| `workspace.archived` | `workspace` | Archive state is saved | - -Agent events exclude the daemon's private disposable helpers. Public background workspaces and -their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede -runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters. - -`agent.turn_ended.timeline` is deprecated and always empty. Fetch history on demand with -`context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })`. -The hook payload size does not grow with conversation history. - -**Shared payload shapes** (`@getpaseo/plugin/server`): - -```ts -interface PluginHookAgent { - id: string; - workspaceId: string | null; - parentAgentId: string | null; - provider: string; - cwd: string; - title: string | null; -} - -interface PluginHookWorkspace { - id: string; - projectId: string; - cwd: string; - name: string | null; - archivedAt: string | null; -} - -type PluginTurnOutcome = - | { kind: "completed" } - | { kind: "failed"; error: { message: string; code?: string } } - | { kind: "canceled"; reason: string }; -``` - -| Field | Shape / meaning | -| ------------ | ----------------------------------------------------------------------------------------------------- | -| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens | -| `timeline` | `readonly AgentTimelineItem[]`; complete snapshot including earlier conversation; text may span items | -| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | -| `resolution` | SDK `AgentPermissionResponse` | -| `archivedAt` | Timestamp string | - -### Before hooks - -| Name | Request fields | Editable | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | -| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | - -**`agent.create.config`** uses `AgentSessionConfig`: - -| Fields | Constraint | -| --------------------------------------------- | -------------------------------------------------------------------------- | -| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | -| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | -| `title`, `systemPrompt` | Agent configuration | -| `providerOptions` | Opaque provider-specific options | -| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | -| `cwd` | Cannot change | - -**`agent.session_open` request example:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "title": "Workspace notes", - "labels": { "notes/enabled": "true" }, - "tools": [], - "mcpServers": {}, - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| Field | Values | -| ------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `workspaceId` | String or `null` | -| `title` | Read-only string or `null`; creation title on create, stored title on resume. | -| `labels` | Read-only string map; creation labels on create, stored labels on resume. | -| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | -| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | -| `reason` | `create`, `resume`, `refresh`, `import` | -| `purpose` | `interactive`, `history` | -| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | - -### Ordering and returned values - -```text -Creation request - → agent.create hooks (plugin-ID order; registration order within each plugin) - → resolve defaults and validate provider configuration - → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD - → open provider session and save agent configuration -``` - -| Callback returns | Next callback receives | -| ------------------------------------------------------- | --------------------------------------------------- | -| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced | -| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced | -| `undefined` | Unchanged request | -| Throws or returns invalid data | Operation fails; later callbacks do not run | - -There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` -accumulate only across callbacks within the same plugin. Returning a replacement map or array -replaces that scope's prior value. - -### Context and cleanup - -| Contract | Behavior | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `context.paseo` | Existing SDK connected to this daemon | -| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests | -| Input data | Detached snapshot; change state through returned requests or SDK commands | -| Registration result | Idempotent remover, e.g. `const remove = server.on(...); remove();` | -| Reload, disable, removal, shutdown | Remaining registrations removed | -| Unknown hook name | Registration fails | -| Hook timeout | 30 seconds; aborts the signal. A before hook fails the pending operation; an event handler logs an error. | -| Event-handler error | Logged against plugin; original operation continues | -| Event delivery | Live, best effort; no replay, persistence, or automatic retry | -| Event concurrency | Different events may overlap; callback completion order is not guaranteed | - -### Complete examples - -| Plugin | Includes | -| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | -| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | -| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | -| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | - -Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. +Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. ## Screens and sidebar items @@ -2498,7 +2017,7 @@ Hiding preserves the registration, so showing it again restores its position. It an action already in progress. `remove()` is idempotent. Updates after removal do nothing. Paseo removes outstanding buttons when -the plugin installation or host connection is torn down. Return cleanup from the client entry for +the plugin installation is disposed (see [Hosts and lifecycle](#hosts-and-lifecycle)). Return cleanup from the client entry for your subscriptions, timers, and other resources. ## Use the Paseo SDK @@ -2826,9 +2345,11 @@ Plugins are installed per daemon. When the same contribution exists on several c Attachment sources remain scoped to each composer's host. -Workspace panels and Command Center items stay scoped to the active host and exact cached context. -Reload replaces their registrations. Disable, removal, host disconnect, and evaluation failure -remove Command Center items and clear the installation's query state. An already-restored panel tab +A dropped connection keeps client plugin registrations, mounted surfaces, local state, and query caches. Reconnecting through the existing `DaemonClient` object keeps unchanged installations and refreshes their settings. Consume SDK observation snapshots to refresh other daemon state; setup does not run again for an unchanged installation. Daemon calls still depend on connectivity. + +Switching connections (for example, from direct to relay) currently replaces the `DaemonClient` object and reinstalls client plugins. Reload, changed bundles or requirements, disable, plugin removal, and host removal also dispose affected installations and clear their query state. A replacement that fails evaluation leaves its contributions unavailable. + +Workspace panels and Command Center items stay scoped to the active host and exact cached context. An already-restored panel tab remains as unavailable until its matching contribution returns or the user closes it. Panel render failures stay inside the plugin error boundary. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 4fc7380ee3..5375b98c68 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -504,470 +504,11 @@ export default function contribute(client: PluginClientContext) { } ``` -清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。在重新加载、停用、移除、断开连接或 daemon 关闭时,Paseo 还会移除注册、卸载界面、reject 待处理的 RPC、关闭插件的 daemon 会话并停止它的子进程。 +清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。重新加载、停用和移除会销毁受影响的插件安装。停止服务端插件还会关闭它的 daemon 会话并停止其子进程。客户端安装遵循[主机与生命周期](#hosts-and-lifecycle)中的规则,包括在暂时断开连接时保留安装。 ## 生命周期钩子 {#lifecycle-hooks} -在 `index.server.ts` 中: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", (event) => { - console.log(event.agent.id, event.outcome); - }); - - return () => {}; -} -``` - -| 注册 | 回调接收 | 返回 | -| ------------------------------- | ---------------------------------- | --------------------------------------------------- | -| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` 或 `Promise` | -| `server.before(name, callback)` | `({ request }, { paseo, signal })` | 修改后的请求,或返回 `undefined` 保持不变;支持异步 | - -只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 - -`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。 -`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。 - -### 修改已保存的配置 {#change-saved-configuration} - -在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: - -```ts -server.before("agent.create", ({ request }) => { - if (request.config.provider !== "codex") { - return request; - } - - return { - ...request, - config: { - ...request.config, - providerOptions: { - ...request.config.providerOptions, - sandbox_mode: "workspace-write", - approval_policy: "on-request", - }, - }, - }; -}); -``` - -| 输入 | 结果 | -| ------------------------------------------- | ------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | 由展开运算保留 | - -所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 - -### 注入 MCP 服务器 {#inject-mcp-servers} - -需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。 -插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: - -```ts -server.before("agent.session_open", ({ request }) => { - if (request.provider !== "codex") return; - - return { - ...request, - mcpServers: { - ...request.mcpServers, - company: { type: "http", url: "https://tools.example.com/mcp" }, - }, - }; -}); -``` - -`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 -每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 -替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 -Paseo 按插件 ID 顺序合并各插件的最终映射。 - -名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 -不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 -`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent -通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 - -### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 - -### 为 Agent 附加工具 {#attach-tools-to-agents} - -在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。 -此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: - -```ts -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; - -const appendWorkspaceNote = defineAgentTool({ - name: "append_workspace_note", - description: "Append a note to AGENT_NOTES.md in this agent's working directory.", - input: z.object({ note: z.string().min(1).describe("Note to append") }), - async execute({ note }, { agent, signal }) { - const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); - await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); - return { - text: `Saved note to ${notePath}`, - structuredContent: { path: notePath }, - }; - }, -}); - -export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { - if (request.labels["notes/enabled"] !== "true") return; - return { ...request, tools: [...request.tools, appendWorkspaceNote] }; - }); - return () => {}; -} -``` - -通过 SDK 创建 Agent 时设置标签: - -```ts -const agent = await paseo.agents.create({ - config: { provider: "codex/gpt-5.4" }, - cwd: "/projects/shop", - labels: { "notes/enabled": "true" }, -}); -``` - -使用会话打开请求中的字段选择 Agent: - -| 选择依据 | 回调中的条件 | -| ---------- | -------------------------------------------- | -| 标签 | `request.labels["notes/enabled"] === "true"` | -| Provider | `request.provider === "codex"` | -| 工作区 | `request.workspaceId === "workspace-456"` | -| 单个 Agent | `request.agentId === chosenAgentId` | - -每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; -替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 - -从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` -和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: - -```ts -declare function defineAgentTool(tool: { - name: string; - title?: string; - description: string; - input: Input; - execute( - input: z.output, - context: AgentToolContext, - ): AgentToolResult | Promise; -}): AgentToolDefinition; - -interface AgentToolResult { - text?: string; - structuredContent?: unknown; - isError?: boolean; -} -``` - -| 执行 context | 值 | -| ------------ | --------------------------------------------------------------------------------------------------- | -| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | -| `paseo` | 已连接到此 daemon 的 SDK | -| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | - -`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 -输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 -Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 -其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 -关闭或重新打开 Agent 会话会移除旧定义。 - -在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, -Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 - -Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 -同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 -只有该 Agent 能看到这些工具。 - -名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo -工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 -会话打开失败。回调未选中的 Agent 不受影响。 - -| 变更 | Agent 何时收到变更 | -| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | -| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | -| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | -| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | - -如果自动重新打开失败,触发它的提示词会返回该错误。参阅 -[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 - -### 选择工作区隔离方式 {#choose-workspace-isolation} - -```ts -server.before("workspace.create", ({ request }) => { - if (request.source.kind !== "directory") { - return request; - } - - return { - ...request, - source: { - kind: "worktree", - cwd: request.source.path, - action: "branch-off", - }, - }; -}); -``` - -**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 - -### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} - -把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与插件 SDK 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { latestOutputText } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", async (event, context) => { - if (event.outcome.kind === "canceled") { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 - -这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 - -### 回应权限请求 {#answer-a-permission-request} - -使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { shellCommand } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.permission_requested", async (event, context) => { - const command = shellCommand(event.request); - if (command === null) { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - if (/\brm\s+-rf\b/.test(command)) { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "deny", message: "Recursive deletion is blocked." }, - }); - return; - } - - if (command.trim() === "git status") { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "allow" }, - }); - } - }); - - return () => {}; -} -``` - -| 请求 | 结果 | -| ---------------- | -------------------- | -| `rm -rf build` | 拒绝 | -| `git status` | 批准 | -| 其他命令或请求 | 保持待处理,留给用户 | -| 已经处理过的请求 | SDK 响应失败 | - -这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 - -### 事件 {#events} - -| 名称 | 事件字段 | 触发时机 | -| ---------------------------- | ----------------------------------------------------- | ----------------------------- | -| `agent.created` | `agent` | 普通创建完成;不包括导入/恢复 | -| `agent.closed` | `agent` | 一个运行中的 Agent 运行时关闭 | -| `agent.turn_started` | `agent`, `turnId` | 实时轮次开始 | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, 已弃用的 `timeline: []` | 实时轮次完成、失败或被取消 | -| `agent.permission_requested` | `agent`, `request` | 权限请求或问题进入待处理状态 | -| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | 待处理的请求得到回应或被清除 | -| `agent.archived` | `agent`, `archivedAt` | 归档状态已保存 | -| `workspace.created` | `workspace` | 记录已创建;目录可用 | -| `workspace.archived` | `workspace` | 归档状态已保存 | - -Agent 事件不包括 daemon 私有的一次性辅助 Agent。公开的后台工作区及其普通 Agent 与其他工作区运行相同的生命周期钩子。归档事件可能早于运行时/worktree 清理;当清理很重要时,请单独观察生命周期何时完成。 - -`agent.turn_ended.timeline` 已弃用,并且始终为空。需要历史时,请用 `context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })` 按需获取。钩子负载的大小不会随对话历史增长。 - -**共享的负载结构**(`@getpaseo/plugin/server`): - -```ts -interface PluginHookAgent { - id: string; - workspaceId: string | null; - parentAgentId: string | null; - provider: string; - cwd: string; - title: string | null; -} - -interface PluginHookWorkspace { - id: string; - projectId: string; - cwd: string; - name: string | null; - archivedAt: string | null; -} - -type PluginTurnOutcome = - | { kind: "completed" } - | { kind: "failed"; error: { message: string; code?: string } } - | { kind: "canceled"; reason: string }; -``` - -| 字段 | 结构 / 含义 | -| ------------ | ---------------------------------------------------------------------------------------- | -| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | -| `timeline` | `readonly AgentTimelineItem[]`;包含之前对话的完整快照;文本可能跨越多个条目 | -| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | -| `resolution` | SDK 的 `AgentPermissionResponse` | -| `archivedAt` | 时间戳字符串 | - -### before 钩子 {#before-hooks} - -| 名称 | 请求字段 | 可编辑 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | - -**`agent.create.config`** 使用 `AgentSessionConfig`: - -| 字段 | 约束 | -| --------------------------------------------- | -------------------------------------------------------- | -| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | -| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | -| `title`, `systemPrompt` | Agent 配置 | -| `providerOptions` | 不透明的、特定于 Provider 的选项 | -| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | -| `cwd` | 不能修改 | - -**`agent.session_open` 请求示例:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "title": "Workspace notes", - "labels": { "notes/enabled": "true" }, - "tools": [], - "mcpServers": {}, - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| 字段 | 取值 | -| ------------- | -------------------------------------------------------------------------------------------------------- | -| `workspaceId` | 字符串或 `null` | -| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | -| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | -| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | -| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | -| `reason` | `create`、`resume`、`refresh`、`import` | -| `purpose` | `interactive`、`history` | -| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | - -### 顺序与返回值 {#ordering-and-returned-values} - -```text -Creation request - → agent.create hooks (plugin-ID order; registration order within each plugin) - → resolve defaults and validate provider configuration - → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD - → open provider session and save agent configuration -``` - -| 回调返回 | 下一个回调收到 | -| ------------------------------------------------------- | --------------------------------- | -| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | -| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | -| `undefined` | 未改变的请求 | -| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | - -没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 -返回替换的映射或数组会替换该范围内之前的值。 - -### context 与清理 {#context-and-cleanup} - -| 契约 | 行为 | -| -------------------------- | ----------------------------------------------------------------------------------- | -| `context.paseo` | 已连接到此 daemon 的现有 SDK | -| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | -| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | -| 注册结果 | 幂等的移除函数,例如 `const remove = server.on(...); remove();` | -| 重新加载、停用、移除、关闭 | 移除剩余的注册 | -| 未知的钩子名称 | 注册失败 | -| 钩子超时 | 30 秒;会中止 signal。before 钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | -| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | -| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | -| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | - -### 完整示例 {#complete-examples} - -| 插件 | 包含 | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | -| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | -| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | -| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | - -用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 +使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 ## 界面和侧边栏项 {#screens-and-sidebar-items} @@ -2177,7 +1718,7 @@ const behavior: PluginButtonBehavior = { 隐藏或禁用按钮会关闭它的浮层。更新它的 behavior 也会关闭浮层。隐藏会保留注册,因此再次显示时会恢复它 原来的位置。它不会取消已经在进行中的操作。 -`remove()` 是幂等的。移除之后的更新不起作用。当插件安装或主机连接被拆除时,Paseo 会移除尚存的按钮。 +`remove()` 是幂等的。移除之后的更新不起作用。当插件安装被销毁时,Paseo 会移除尚存的按钮(参见[主机与生命周期](#hosts-and-lifecycle))。 请从客户端入口返回清理函数,用来释放你的订阅、定时器和其他资源。 ## 使用 Paseo SDK {#use-the-paseo-sdk} @@ -2496,8 +2037,11 @@ Composer 菜单、搜索选择器、选中后的胶囊、草稿状态和提交 附件来源仍然限定在各个 Composer 所在的主机上。 -工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。重新加载会替换它们的 -注册。停用、移除、主机断开和求值失败会移除 Command Center 条目,并清除该安装的查询状态。已经恢复的面板 +连接中断时,客户端插件的注册、已挂载界面、本地状态和查询缓存会保留。通过原有的 `DaemonClient` 对象重新连接时,未变化的安装会保留,其设置会刷新。请使用 SDK 观察快照刷新其他 daemon 状态;未变化的安装不会再次运行初始化函数。调用 daemon 仍然依赖连接。 + +切换连接(例如从直连切换到中继)目前会替换 `DaemonClient` 对象并重新安装客户端插件。重新加载、插件包或版本要求变化、停用、移除插件和移除主机也会销毁受影响的安装,并清除其查询状态。如果替换安装求值失败,它的贡献将不可用。 + +工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。已经恢复的面板 标签页会保持“不可用”状态,直到与之匹配的贡献重新出现或用户关闭它。面板的渲染失败会被限制在插件的错误 边界内。 diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 5bb1db793f..baaea2e177 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -35,6 +35,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory: } ``` +Plugins can choose a location anywhere on disk with [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore). The [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) places worktrees next to the source checkout. Paseo validates the path, refuses an existing destination, and manages listing, archive, and deletion there. Recovery uses the recorded path without running creation hooks. + 1. Create a workspace with worktree isolation, Paseo creates the worktree and runs your setup hooks 2. Launch one or more agents in that workspace 3. Review the diff against the base branch @@ -80,6 +82,19 @@ paseo workspace create \ Add `--forge ` when Paseo cannot infer the forge from the source checkout. +## Copy local files with .worktreeinclude + +Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees: + +```gitignore +.env +config/*.local.json +``` + +Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation. + +The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere. + ## paseo.json Drop a `paseo.json` in your repo root. Paseo reads it from the committed version of the base branch you picked, so uncommitted changes in other branches don't apply. @@ -99,12 +114,14 @@ Drop a `paseo.json` in your repo root. Paseo reads it from the committed version ## Setup and teardown -`setup` runs once after the worktree is created. A fresh worktree has no installed dependencies and no ignored files (like `.env`), so use setup to install and copy what you need. `teardown` runs during archive, before the directory is removed. +Plugins can use [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) to write files before setup, terminals, and the first agent. + +`setup` runs once after the worktree is created. Use setup to install dependencies and run project initialization. Use [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) to copy ignored files such as `.env`. `teardown` runs during archive, before the directory is removed. ```json { "worktree": { - "setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate", + "setup": "npm ci\nnpm run db:migrate", "teardown": "npm run db:drop || true" } } diff --git a/public-docs/worktrees.zh-CN.md b/public-docs/worktrees.zh-CN.md index 0bf3af55a4..e84d0a8a25 100644 --- a/public-docs/worktrees.zh-CN.md +++ b/public-docs/worktrees.zh-CN.md @@ -33,6 +33,8 @@ worktree 默认存放在 `$PASEO_HOME/worktrees/` 下,按源检出路径的哈 } ``` +插件可以通过 [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore) 将 worktree 放到磁盘上的自选位置。[worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) 将其放在源检出目录旁边。Paseo 验证路径并拒绝已存在的目标,同时支持在该位置列出、归档和删除 worktree。恢复时使用记录的原路径,不运行创建钩子。 + 1. 创建一个使用 worktree 隔离的工作区,Paseo 会创建 worktree 并运行你的 setup 钩子 2. 在该工作区中启动一个或多个 Agent 3. 对照基础分支审查 diff @@ -78,6 +80,19 @@ paseo workspace create \ 当 Paseo 无法从源检出推断代码托管平台时,添加 `--forge `。 +## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude} + +在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree: + +```gitignore +.env +config/*.local.json +``` + +模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。 + +内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。 + ## paseo.json {#paseojson} 在仓库根目录放一个 `paseo.json`。Paseo 从你所选基础分支的已提交版本中读取它,因此其他分支中未提交的改动不会生效。 @@ -97,12 +112,14 @@ paseo workspace create \ ## Setup 与 teardown {#setup-and-teardown} -`setup` 在 worktree 创建后运行一次。新的 worktree 没有安装依赖,也没有被忽略的文件(例如 `.env`),因此请用 setup 安装和复制你需要的内容。`teardown` 在归档期间、目录被删除之前运行。 +插件可以通过 [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) 在 setup、终端和第一个 Agent 运行之前写入文件。 + +`setup` 在 worktree 创建后运行一次。使用 setup 安装依赖并初始化项目。使用 [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) 复制 `.env` 等被忽略的文件。`teardown` 在归档期间、目录被删除之前运行。 ```json { "worktree": { - "setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate", + "setup": "npm ci\nnpm run db:migrate", "teardown": "npm run db:drop || true" } } diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c7e631c529..37508cad7d 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -157,6 +157,14 @@ test("focused contracts stay inside existing required checks", () => { assert.ok(!jobs.has("playwright-desktop")); }); +test("ordinary CI keeps app tests without prebuilding Android modules", () => { + const source = readFileSync(ciWorkflowPath, "utf8"); + const app = jobBlocks(source).get("app-tests").join("\n"); + assert.match(app, /npm run build:app-deps/); + assert.match(app, /npm run test --workspace=@getpaseo\/app/); + assert.doesNotMatch(source, /test:android|expo prebuild|gradlew|actions\/setup-java/); +}); + test("server builds exclude test utilities at every domain depth", () => { const tsconfig = JSON.parse(readFileSync(serverTsconfigPath, "utf8")); assert.ok(tsconfig.exclude.includes("src/server/**/test-utils/**"));