Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 5216c3b0a2 test(plugins): allow time for Windows legacy migration workflows
The migration case runs real Git operations and several plugin subprocess restarts. Windows CI exceeded its 30-second test deadline and entered cleanup while the operation was still active. Keep every assertion and allow 60 seconds for the complete workflow.
2026-10-28 10:26:54 +01:00
180 changed files with 2388 additions and 14440 deletions
-25
View File
@@ -203,31 +203,6 @@
"readFileSync", "readFileSync",
"rmSync" "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. // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"], "terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
-4
View File
@@ -10,8 +10,6 @@ Voice settings now let you configure speech providers and download local models
### Added ### 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)) - 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)) - 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)) - Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
@@ -23,8 +21,6 @@ Voice settings now let you configure speech providers and download local models
### Improved ### 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)) - 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 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)) - Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
-2
View File
@@ -21,8 +21,6 @@
### 改进 ### 改进
- 新增采用 `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 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) - 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
-1
View File
@@ -66,7 +66,6 @@ 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/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/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/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 | | [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 | | [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 | | [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
-3
View File
@@ -66,9 +66,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
retaining the daemon's runtime and wire-schema allocations. The server export tests guard 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. 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 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 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 and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
+1 -1
View File
@@ -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 `<Alert>` (`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 `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region. Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`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 `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for. Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page. Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
-1
View File
@@ -34,7 +34,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. - **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). - **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
+13 -18
View File
@@ -99,10 +99,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI `packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load. checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource, Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar` including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation. filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
@@ -440,22 +436,21 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
## Lifecycle hooks ## Lifecycle hooks
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup Server entries register lifecycle observers with `server.on()` and request transforms with
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md) owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on re-derived on each open and following the plugin's lifecycle. See
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary. [Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
Worktree placement is resolved before Git source planning, so a hook can change the branch and base eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks. Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record, as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
ownership checks prefer the marker-verified worktree root over the legacy executable definitions stay in the plugin process.
path shape: a plugin can place a worktree several directories below the default worktrees root.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
or answer a permission through its own daemon session. Awaiting it there deadlocks that command. answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider ## Contribute a provider
-69
View File
@@ -1,69 +0,0 @@
# Terminal worker
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
## Process model
```
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
exits (re-parented to init)
```
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
## Protocol versions
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
| Worker reports | Daemon does |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| its own version | adopts it |
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
- Dev (`.ts` source) launches from source and copies nothing.
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
## Desktop
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
## Known limits
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
@@ -8,11 +8,7 @@ import {
openDaemonOverview, openDaemonOverview,
restartDaemonInSettings, restartDaemonInSettings,
expectDaemonRestartComplete, expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
readWorkerPid, readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
} from "../support/helpers/daemon-lifecycle"; } from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({ test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -33,57 +29,6 @@ test("settings restart waits for a replacement worker while retaining the superv
} }
}); });
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({ test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page, page,
}) => { }) => {
@@ -1,69 +0,0 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { expect, metroTest as test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
const PUBLISHED_DAEMON_VERSION = "0.8.0";
const COMPACT = { width: 390, height: 844 };
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: PUBLISHED_DAEMON_VERSION,
});
try {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ seededHost }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
},
{ seededHost: host },
);
await gotoAppShell(page);
await run();
} finally {
await daemon.close();
}
}
async function expectOutdatedNotice(page: Page): Promise<void> {
const callout = page.getByTestId("daemon-outdated-callout").first();
await expect(callout).toBeVisible({ timeout: 30_000 });
await expect(callout).toContainText("Daemon out of date");
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
const update = page.getByTestId("daemon-outdated-callout-update").first();
await expect(update).toBeVisible();
await expect(update).toBeEnabled();
}
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
page,
}) => {
test.setTimeout(180_000);
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
});
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize(COMPACT);
await withOutdatedDaemon(page, async () => {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expectOutdatedNotice(page);
});
});
@@ -415,19 +415,8 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" }); const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId); const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]'); await openWorkspace(page, workspace);
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
await page.setViewportSize({ width: 1280, height: 900 });
await openWorkspace(page, workspace);
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 390, height: 844 });
});
await page.getByRole("button", { name: "Open menu", exact: true }).click(); await page.getByRole("button", { name: "Open menu", exact: true }).click();
// The compact sidebar shows only native callouts, so the prompt stays out.
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
await expect(worktreeSetupCallout).toHaveCount(0);
await page.getByTestId("sidebar-hosts-trigger").click(); await page.getByTestId("sidebar-hosts-trigger").click();
await page.getByRole("button", { name: /Open .* settings/ }).click(); await page.getByRole("button", { name: /Open .* settings/ }).click();
@@ -1,15 +1,8 @@
import type { Locator, Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
type WebSocketMessage = string | Buffer; type WebSocketMessage = string | Buffer;
@@ -122,27 +115,23 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
}); });
}); });
function appendCompletedCalls(extraCompletedCalls: number) {
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${sequenceOffset + index}`,
sequenceOffset + index + 1,
),
);
}
sequenceOffset += extraCompletedCalls;
}
return { return {
waitForFirstCompleted: () => firstCompleted, waitForFirstCompleted: () => firstCompleted,
appendCompletedCalls,
release(extraCompletedCalls = 0) { release(extraCompletedCalls = 0) {
isHolding = false; isHolding = false;
appendCompletedCalls(extraCompletedCalls); if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${index}`,
index + 1,
),
);
}
sequenceOffset = extraCompletedCalls;
for (const message of heldMessages.splice(0)) { for (const message of heldMessages.splice(0)) {
sendToPage?.(shiftTimelineSequence(message, sequenceOffset)); sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
} }
@@ -150,24 +139,17 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
}; };
} }
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns"; async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
await page.addInitScript((display) => {
localStorage.setItem( localStorage.setItem(
"@paseo:app-settings", "@paseo:app-settings",
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }), JSON.stringify({ chatDisplay: "collapseToolCalls" }),
); );
}, chatDisplay); });
} }
async function createOverviewAgent( async function createOverviewAgent(page: Page, title: string) {
page: Page, await configureOverviewToolCalls(page);
title: string,
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
) {
await configureOverviewToolCalls(page, chatDisplay);
return seedMockAgentWorkspace({ return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-", repoPrefix: "tool-call-overview-sheet-",
title, title,
@@ -222,36 +204,6 @@ function readScrollMetrics(root: HTMLElement) {
}; };
} }
const FIRST_CALL_DETAILS = "export function ConversationList()";
function firstToolCall(page: Page): Locator {
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
}
async function openToolDetails(badge: Locator): Promise<void> {
await badge.getByRole("button").first().click();
}
async function timelineRowOf(locator: Locator) {
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
if (!row) throw new Error("Expected the locator inside a timeline row");
return row;
}
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
async function isInsideRow(
locator: Locator,
row: Awaited<ReturnType<typeof timelineRowOf>>,
): Promise<boolean> {
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
}
async function topOf(row: Locator): Promise<number> {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible timeline row");
return box.y;
}
test.describe("compact overview tool calls", () => { test.describe("compact overview tool calls", () => {
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true }); test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
@@ -267,16 +219,8 @@ test.describe("compact overview tool calls", () => {
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet."); await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
await gate.waitForFirstCompleted(); await gate.waitForFirstCompleted();
const single = firstToolCall(page);
await expect(single).toBeVisible();
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
await openToolDetails(single);
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
await page.getByTestId("tool-call-sheet-close").click();
gate.appendCompletedCalls(1);
const group = page.getByTestId("tool-call-group").first(); const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible(); await expect(group).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
await group.click(); await group.click();
const sheet = page.getByTestId("tool-call-group-sheet"); const sheet = page.getByTestId("tool-call-group-sheet");
@@ -317,86 +261,27 @@ test.describe("compact overview tool calls", () => {
}); });
}); });
for (const display of ["collapseToolCalls", "collapseTurns"] as const) { test("keeps overview tool calls inline on desktop", async ({ page }) => {
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
await recordReactRenders(page);
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
const details = page.getByText(FIRST_CALL_DETAILS);
await test.step("a lone tool call shows as itself", async () => {
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
});
await gate.waitForSecondRunning();
await test.step("opening it re-renders that tool call and nothing else", async () => {
await resetReactRenders(page);
await openToolDetails(lone);
await expect(details).toBeVisible();
const renders = await readReactRenders(page);
expect(renders.AgentStreamView ?? 0).toBe(0);
expect(renders.StreamRowContent ?? 0).toBe(0);
expect(renders.ToolCall).toBe(1);
});
await test.step("the second call forms an open group around the opened call", async () => {
// Reading above the tail, the row holds still; at the tail the new rows push it up.
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
const loneTop = await topOf(lone);
const loneRow = await timelineRowOf(lone);
await resetReactRenders(page);
gate.releaseSecondRunning();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
expect(await isInsideRow(group, loneRow)).toBe(true);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
});
await test.step("closing and reopening the group keeps the call open", async () => {
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
await expect(details).toHaveCount(0);
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
});
} finally {
await agent.cleanup();
}
});
}
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls"); const agent = await createOverviewAgent(page, "Desktop overview tool calls");
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
await gate.waitForSecondRunning(); try {
const loneTop = await topOf(lone); const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
gate.releaseSecondRunning(); await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible(); await expect(group).toBeVisible();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2); // Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
} finally { } finally {
await agent.cleanup(); await agent.cleanup();
} }
@@ -1,9 +1,11 @@
import type { Locator } from "@playwright/test"; import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence { interface ShimmerEvidence {
animationDuration: string; animationDuration: string;
endPx: number; endPx: number;
@@ -12,6 +14,127 @@ interface ShimmerEvidence {
startPx: number; startPx: number;
} }
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> { async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => { return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) => const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
@@ -1,35 +0,0 @@
# Native daemon-outdated notice
The flow needs the development app connected to an isolated daemon older than the app. Install a published daemon that predates `terminalsSurviveRestart`, start it in its own home, and bundle the app against it. `<checkout>` is the root of this repository; `<port>`, `<metro-port>` and `<udid>` are a free daemon port, a free Metro port and the booted simulator.
Run this once, in any directory. It installs into `/tmp/old-daemon`, not globally:
```sh
npm install --prefix /tmp/old-daemon --no-package-lock @getpaseo/server@0.8.0
```
Terminal 1, the old daemon. It runs in the foreground; keep it running:
```sh
cd /tmp/old-daemon/node_modules/@getpaseo/server
PASEO_HOME=/tmp/old-daemon-home PASEO_LISTEN=0.0.0.0:<port> PASEO_RELAY_ENABLED=0 node dist/scripts/supervisor-entrypoint.js
```
Terminal 2, Metro. It runs in the foreground; keep it running:
```sh
cd <checkout>/packages/app
APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON=localhost:<port> npx expo start --port <metro-port> --clear
```
Terminal 3, the flow, while terminals 1 and 2 are still running:
```sh
cd <checkout>
xcrun simctl openurl <udid> "paseo://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A<metro-port>"
maestro --udid <udid> test packages/app/e2e/mobile/agent-device/native-daemon-outdated-notice.ios.yaml --env DAEMON_VERSION=0.8.0
```
Start with the sidebar closed. The flow opens it, then asserts the notice, its daemon version, the terminals line for a daemon without `terminalsSurviveRestart`, and an enabled Update button. Do not tap Update: on 0.8.0 it runs a global `npm install -g @getpaseo/cli@latest`.
When you are done, stop terminals 1 and 2 and remove `/tmp/old-daemon` and `/tmp/old-daemon-home`.
@@ -1,26 +0,0 @@
appId: sh.paseo.debug
---
# The app must be connected to a daemon older than the app that predates
# terminalsSurviveRestart (0.8.0). See native-daemon-outdated-notice-preconditions.md.
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- extendedWaitUntil:
visible: "Open menu"
timeout: 60000
- tapOn: "Open menu"
- extendedWaitUntil:
visible:
id: daemon-outdated-callout
timeout: 30000
- assertVisible: "Daemon out of date"
- assertVisible: ".*daemon v${DAEMON_VERSION}.*"
- assertVisible: "Updating restarts the daemon and closes open terminals."
- assertVisible:
id: daemon-outdated-callout-update
enabled: true
- takeScreenshot: native-daemon-outdated-notice
@@ -1,7 +1,3 @@
import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader"; import { connectDaemonClient } from "./daemon-client-loader";
@@ -19,27 +15,14 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
await openHostSection(page, daemon.serverId, "host"); await openHostSection(page, daemon.serverId, "host");
} }
export async function restartDaemonInSettings( export async function restartDaemonInSettings(page: Page) {
page: Page, const button = page.getByRole("button", { name: "Restart", exact: true });
options: { closeTerminals?: boolean } = {},
) {
const button = page.getByRole("button", {
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
exact: true,
});
await expect(button).toBeEnabled(); await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept()); page.once("dialog", (dialog) => dialog.accept());
await button.click(); await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
} }
export async function expectDaemonRestartFailed(page: Page, text: string) {
const error = page.getByTestId("host-page-restart-error");
await expect(error).toBeVisible({ timeout: 30_000 });
await expect(error).toContainText(text);
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
}
export async function expectDaemonRestartComplete(page: Page) { export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({ await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000, timeout: 30_000,
@@ -57,100 +40,3 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
await client.close(); await client.close();
} }
} }
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminal",
});
try {
const workspace = await client.createWorkspace({
source: { kind: "directory", path: daemon.paseoHome },
title: "lifecycle",
});
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
workspaceId: workspace.workspace.id,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1e9)"],
});
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
return created.terminal.id;
} finally {
await client.close();
}
}
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminals",
});
try {
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
} finally {
await client.close();
}
}
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
export interface UnclosableTerminalProgram {
pid: number;
/** Ends the program's parent, which lets the system reap the program. */
release(): void;
}
/**
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
* it. A kill cleanup can only report it as still running. POSIX only.
*/
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
const record = {
recordId: "planted-record",
pid: gone.pid,
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: path.join(daemon.paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
};
await writeFile(
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
return { pid, release };
} catch (error) {
release();
throw error;
}
}
@@ -1,78 +0,0 @@
import type { Page } from "@playwright/test";
declare global {
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | undefined;
}
/**
* Counts which components render in each React commit, by name, the way React DevTools
* highlights updates: a fiber rendered when React performed work on it in that commit.
* Install before the app loads; React registers with the DevTools hook at startup.
*/
export async function recordReactRenders(page: Page): Promise<void> {
await page.addInitScript(() => {
const PERFORMED_WORK = 1;
interface Fiber {
type: unknown;
flags: number;
child: Fiber | null;
sibling: Fiber | null;
alternate: Fiber | null;
}
const nameOf = (type: unknown): string | null => {
if (typeof type === "function") {
return (type as { displayName?: string }).displayName || type.name || null;
}
if (!type || typeof type !== "object") return null;
const component = type as { displayName?: string; render?: unknown; type?: unknown };
return component.displayName || nameOf(component.render) || nameOf(component.type);
};
const record = (root: Fiber) => {
const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {});
const pending: Fiber[] = [root];
while (pending.length > 0) {
const fiber = pending.pop()!;
const name = typeof fiber.type === "string" ? null : nameOf(fiber.type);
if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1;
// A subtree React bailed out of is shared with the previous commit; skip it.
if (fiber.alternate && fiber.child === fiber.alternate.child) continue;
for (let child = fiber.child; child; child = child.sibling) pending.push(child);
}
};
let rendererCount = 0;
Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
configurable: true,
value: {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject(renderer: unknown) {
rendererCount += 1;
this.renderers.set(rendererCount, renderer);
return rendererCount;
},
checkDCE() {},
onScheduleFiberRoot() {},
onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) {
record(root.current);
},
onPostCommitFiberRoot() {},
onCommitFiberUnmount() {},
},
});
});
}
export async function resetReactRenders(page: Page): Promise<void> {
await page.evaluate(() => {
globalThis.__PASEO_E2E_REACT_RENDERS__ = {};
});
}
/** Render counts per component name since the last reset, read after the next painted frame. */
export async function readReactRenders(page: Page): Promise<Record<string, number>> {
return page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
});
}
@@ -1,125 +0,0 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
export async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
+52 -57
View File
@@ -61,11 +61,6 @@ import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card"; import { QuestionFormCard } from "@/components/question-form-card";
import {
ToolCallExpansionContext,
useAgentToolCallExpansion,
useExpandedToolCallGroups,
} from "@/tool-calls/expansion-context";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation"; import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
@@ -369,8 +364,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>( const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(), new Set(),
); );
const toolCallExpansion = useAgentToolCallExpansion(agentId); const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion); new Set(),
);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set()); const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided) // Get serverId (fallback to agent's serverId if not provided)
@@ -435,6 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => { useEffect(() => {
setIsNearBottom(true); setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set()); setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set()); setExpandedTurnHostIds(new Set());
}, [agentId]); }, [agentId]);
@@ -711,7 +708,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none"; return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
}); });
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(groupId);
} else {
next.delete(groupId);
}
return next;
});
}, []);
const renderUserMessageItem = useCallback( const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => { (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
@@ -808,7 +815,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
( (
item: Extract<StreamItem, { kind: "tool_call" }>, item: Extract<StreamItem, { kind: "tool_call" }>,
isLastInSequence: boolean, isLastInSequence: boolean,
options: { maxDetailHeight?: number; standalone?: boolean } = {}, maxDetailHeight?: number,
) => { ) => {
const { payload } = item; const { payload } = item;
@@ -840,8 +847,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentMessage={data.agentMessage} agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence} isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile} onOpenFilePath={handleToolCallOpenFile}
standalone={options.standalone} maxDetailHeight={maxDetailHeight}
maxDetailHeight={options.maxDetailHeight}
/> />
); );
} }
@@ -857,8 +863,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status} status={data.status}
isLastInSequence={isLastInSequence} isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile} onOpenFilePath={handleToolCallOpenFile}
standalone={options.standalone} maxDetailHeight={maxDetailHeight}
maxDetailHeight={options.maxDetailHeight}
/> />
); );
}, },
@@ -881,7 +886,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
? renderThoughtItem(layoutItem, item) ? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); : renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
} }
const expanded = expandedToolCallGroups.has(group.run.id); const expanded = expandedToolCallGroupIds.has(group.run.id);
return ( return (
<OverviewToolCallGroupView <OverviewToolCallGroupView
group={group} group={group}
@@ -901,14 +906,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={call.status} status={call.status}
isLastInSequence={index === group.run.calls.length - 1} isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false} defaultExpanded={false}
standalone={false}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded} onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/> />
) : ( ) : (
renderSingleToolCallItem(call, index === group.run.calls.length - 1, { renderSingleToolCallItem(
maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, call,
standalone: false, index === group.run.calls.length - 1,
}) GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
)} )}
</React.Fragment> </React.Fragment>
)) ))
@@ -917,7 +922,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
}, },
[ [
expandedToolCallGroups, expandedToolCallGroupIds,
renderThoughtItem, renderThoughtItem,
setInlineDetailsExpanded, setInlineDetailsExpanded,
getToolCallGroup, getToolCallGroup,
@@ -1169,18 +1174,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() => ({ () => ({
contentById: presentation.historyGroupUpdatesByHostId, contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: { displayStateById: {
// An opened lone call also marks its future group open; that row stays unrevised
// until a group actually forms there.
has: (id: string) => has: (id: string) =>
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) || expandedToolCallGroupIds.has(id) ||
expandedTurnHostIds.has(id) || expandedTurnHostIds.has(id) ||
nestedItemIds.has(id), nestedItemIds.has(id),
}, },
globalDisplayState: isMobile, globalDisplayState: isMobile,
}), }),
[ [
expandedToolCallGroups, expandedToolCallGroupIds,
getToolCallGroup,
expandedTurnHostIds, expandedTurnHostIds,
isMobile, isMobile,
nestedItemIds, nestedItemIds,
@@ -1205,32 +1207,30 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<ToolCallSheetProvider> <ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}> <AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing> <MessageOuterSpacingProvider disableOuterSpacing>
<ToolCallExpansionContext.Provider value={toolCallExpansion}> {streamRenderStrategy.render({
{streamRenderStrategy.render({ readingRowReplacements: presentation.readingRowReplacements,
readingRowReplacements: presentation.readingRowReplacements, agentId,
agentId, segments: renderModel.segments,
segments: renderModel.segments, historyRowRevision,
historyRowRevision, liveHeadRowRevision: expandedToolCallGroupIds,
liveHeadRowRevision: expandedToolCallGroups, boundary,
boundary, renderers,
renderers, listEmptyComponent,
listEmptyComponent, viewportRef,
viewportRef, routeBottomAnchorRequest,
routeBottomAnchorRequest, isAuthoritativeHistoryReady,
isAuthoritativeHistoryReady, onNearBottomChange: setIsNearBottom,
onNearBottomChange: setIsNearBottom, onReadingPositionChange: handleReadingPositionChange,
onReadingPositionChange: handleReadingPositionChange, onNearHistoryStart: loadOlder,
onNearHistoryStart: loadOlder, isLoadingOlderHistory: isLoadingOlder,
isLoadingOlderHistory: isLoadingOlder, hasOlderHistory: hasOlder,
hasOlderHistory: hasOlder, olderHistoryProgressKey: progressKey,
olderHistoryProgressKey: progressKey, scrollEnabled: streamScrollEnabled,
scrollEnabled: streamScrollEnabled, listStyle: stylesheet.list,
listStyle: stylesheet.list, baseListContentContainerStyle: stylesheet.listContentContainer,
baseListContentContainerStyle: stylesheet.listContentContainer, forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer, contentMaxWidth,
contentMaxWidth, })}
})}
</ToolCallExpansionContext.Provider>
</MessageOuterSpacingProvider> </MessageOuterSpacingProvider>
<ChatOutlineRail <ChatOutlineRail
prompts={chatOutline.prompts} prompts={chatOutline.prompts}
@@ -1403,7 +1403,6 @@ interface ThoughtSlotProps {
status: Extract<StreamItem, { kind: "thought" }>["status"]; status: Extract<StreamItem, { kind: "thought" }>["status"];
isLastInSequence: boolean; isLastInSequence: boolean;
defaultExpanded: boolean; defaultExpanded: boolean;
standalone?: boolean;
} }
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text. // Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
@@ -1414,7 +1413,6 @@ function ThoughtSlot({
status, status,
isLastInSequence, isLastInSequence,
defaultExpanded, defaultExpanded,
standalone,
}: ThoughtSlotProps) { }: ThoughtSlotProps) {
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming"); const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return ( return (
@@ -1426,7 +1424,6 @@ function ThoughtSlot({
status={status === "ready" ? "completed" : "executing"} status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence} isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded} defaultExpanded={defaultExpanded}
standalone={standalone}
forceInline={defaultExpanded} forceInline={defaultExpanded}
/> />
); );
@@ -1441,9 +1438,7 @@ function ToolCallSlot({
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded), (expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId], [onInlineDetailsExpandedChangeByItemId, itemId],
); );
return ( return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
);
} }
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
-2
View File
@@ -84,7 +84,6 @@ import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings"; import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { DaemonOutdatedCalloutSource } from "@/components/daemon-outdated-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
@@ -487,7 +486,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DownloadToast /> <DownloadToast />
<RosettaCalloutSource /> <RosettaCalloutSource />
<UpdateCalloutSource /> <UpdateCalloutSource />
<DaemonOutdatedCalloutSource />
<LegacyAgentSkillsMigration /> <LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource /> <WorktreeSetupCalloutSource />
<CommandCenterRootActions /> <CommandCenterRootActions />
@@ -1,182 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type SidebarCalloutAction,
SidebarCalloutDescriptionText,
} from "@/components/sidebar-callout";
import {
type DaemonOutdatedUpdateState,
resolveDaemonOutdatedCallout,
} from "@/components/resolve-daemon-outdated-callout";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { updateDaemonFromSettings } from "@/screens/settings/daemon-lifecycle";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { orderHostsLocalFirst, resolveActiveHostServerId } from "@/types/host-connection";
import { resolveAppVersion } from "@/utils/app-version";
import { confirmDialog } from "@/utils/confirm-dialog";
interface HostUpdateState {
serverId: string;
update: DaemonOutdatedUpdateState;
}
const IDLE: DaemonOutdatedUpdateState = { status: "idle" };
function DaemonOutdatedDescription({ lines }: { lines: string[] }) {
return (
<>
{lines.map((line) => (
<SidebarCalloutDescriptionText key={line}>{line}</SidebarCalloutDescriptionText>
))}
</>
);
}
interface ConfirmAndUpdateDaemonInput {
serverId: string;
hostLabel: string;
client: DaemonClient;
t: TFunction;
onState: (next: DaemonOutdatedUpdateState) => void;
}
async function confirmAndUpdateDaemon(input: ConfirmAndUpdateDaemonInput): Promise<void> {
const { serverId, hostLabel, client, t, onState } = input;
let confirmed: boolean;
try {
confirmed = await confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: hostLabel }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
});
} catch (error) {
console.error("[DaemonOutdatedCallout] Failed to open update confirmation", error);
onState({ status: "failed", message: t("settings.host.daemon.update.dialogFailedMessage") });
return;
}
if (!confirmed) return;
const phaseLabels = {
starting: t("settings.host.daemon.update.phaseStarting"),
downloading: t("settings.host.daemon.update.phaseDownloading"),
installing: t("settings.host.daemon.update.phaseInstalling"),
complete: t("settings.host.daemon.update.phaseComplete"),
};
onState({ status: "updating", phase: phaseLabels.starting });
const requestId = `sidebar_daemon_update_${serverId}`;
const unsubscribe = client.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
onState({ status: "updating", phase: phaseLabels[message.payload.phase] });
});
try {
await updateDaemonFromSettings(serverId, {
updateDaemon: () => client.updateDaemon(requestId),
getStatus: async () => ({
...(await client.getDaemonStatus()),
serverId: client.getLastServerInfoMessage()?.serverId ?? "",
version: client.getLastServerInfoMessage()?.version ?? null,
}),
});
onState({ status: "idle" });
} catch (error) {
console.error(`[DaemonOutdatedCallout] Failed to update daemon ${hostLabel}`, error);
onState({
status: "failed",
message: t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : String(error),
}),
});
} finally {
unsubscribe();
}
}
/** Sidebar notice when the active host's daemon is older than this app. */
export function DaemonOutdatedCalloutSource() {
const { t } = useTranslation();
const callouts = useSidebarCallouts();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const lastWorkspaceSelection = useLastWorkspaceSelection();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
// Same host resolution as settings: the last workspace's host, else the local daemon, else the first.
const serverId = resolveActiveHostServerId({
selectedServerId: lastWorkspaceSelection?.serverId ?? null,
localServerId,
hosts,
orderedHosts,
});
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? "";
const daemonClient = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const serverInfo = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.serverInfo ?? null) : null,
);
const [hostUpdate, setHostUpdate] = useState<HostUpdateState | null>(null);
const update = hostUpdate && hostUpdate.serverId === serverId ? hostUpdate.update : IDLE;
const runUpdate = useStableEvent(() => {
if (!serverId || !daemonClient) return;
void confirmAndUpdateDaemon({
serverId,
hostLabel,
client: daemonClient,
t,
onState: (next) => setHostUpdate({ serverId, update: next }),
});
});
useEffect(() => {
if (!serverId || !serverInfo) return;
const descriptor = resolveDaemonOutdatedCallout({
serverId,
hostLabel,
appVersion: resolveAppVersion(),
daemonVersion: serverInfo.version,
supportsSelfUpdate: serverInfo.features?.daemonSelfUpdate === true,
desktopManaged: serverInfo.desktopManaged === true,
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
terminalsSurviveRestart: serverInfo.features?.terminalsSurviveRestart === true,
isConnected,
update,
});
if (!descriptor) return;
const actions: SidebarCalloutAction[] = descriptor.updateAction
? [
{
label: descriptor.updateAction.label,
onPress: runUpdate,
variant: "primary",
disabled: descriptor.updateAction.disabled,
testID: "daemon-outdated-callout-update",
},
]
: [];
return callouts.show({
id: descriptor.id,
dismissalKey: descriptor.dismissalKey,
priority: descriptor.priority,
title: descriptor.title,
description: <DaemonOutdatedDescription lines={descriptor.description} />,
variant: descriptor.variant,
actions,
testID: descriptor.testID,
native: true,
});
// t: the resolver reads the active language through i18n, so a language change rebuilds the copy.
}, [callouts, hostLabel, isConnected, runUpdate, serverId, serverInfo, t, update]);
return null;
}
+1 -4
View File
@@ -40,7 +40,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session"; import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -606,8 +605,6 @@ function MobileSidebar({
/> />
)} )}
<SidebarCalloutSlot nativeOnly />
<SidebarFooter <SidebarFooter
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleSettings={handleSettings} handleSettings={handleSettings}
@@ -787,7 +784,7 @@ function DesktopSidebar({
/> />
)} )}
<SidebarCalloutSlot nativeOnly={isNative} /> <SidebarCalloutSlot />
<SidebarFooter <SidebarFooter
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
+2 -15
View File
@@ -27,7 +27,6 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title"; import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentMessage } from "@getpaseo/protocol/agent-message";
@@ -2242,9 +2241,6 @@ interface ToolCallProps {
defaultExpanded?: boolean; defaultExpanded?: boolean;
forceInline?: boolean; forceInline?: boolean;
maxDetailHeight?: number; maxDetailHeight?: number;
itemId: string;
/** Shown as its own row rather than inside a tool call group. */
standalone?: boolean;
} }
export const ToolCall = memo(function ToolCall({ export const ToolCall = memo(function ToolCall({
@@ -2266,15 +2262,9 @@ export const ToolCall = memo(function ToolCall({
defaultExpanded, defaultExpanded,
forceInline = false, forceInline = false,
maxDetailHeight = 400, maxDetailHeight = 400,
itemId,
standalone = true,
}: ToolCallProps) { }: ToolCallProps) {
const { openToolCall } = useToolCallSheet(); const { openToolCall } = useToolCallSheet();
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({ const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
callId: itemId,
defaultExpanded: defaultExpanded ?? false,
standalone,
});
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const shouldRenderInline = !isMobile || forceInline; const shouldRenderInline = !isMobile || forceInline;
@@ -2329,11 +2319,10 @@ export const ToolCall = memo(function ToolCall({
showLoadingSkeleton: presentation.isLoadingDetails, showLoadingSkeleton: presentation.isLoadingDetails,
}); });
} else { } else {
toggleExpanded(); setIsExpanded((prev) => !prev);
} }
}, [ }, [
shouldRenderInline, shouldRenderInline,
toggleExpanded,
openToolCall, openToolCall,
toolName, toolName,
presentation.detail, presentation.detail,
@@ -2439,7 +2428,5 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.defaultExpanded !== next.defaultExpanded) return false;
if (previous.forceInline !== next.forceInline) return false; if (previous.forceInline !== next.forceInline) return false;
if (previous.maxDetailHeight !== next.maxDetailHeight) return false; if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
if (previous.itemId !== next.itemId) return false;
if (previous.standalone !== next.standalone) return false;
return true; return true;
} }
@@ -1,195 +0,0 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveDaemonOutdatedCallout,
type ResolveDaemonOutdatedCalloutInput,
} from "./resolve-daemon-outdated-callout";
function input(
overrides: Partial<ResolveDaemonOutdatedCalloutInput> = {},
): ResolveDaemonOutdatedCalloutInput {
return {
serverId: "srv_alpha",
hostLabel: "Workstation",
appVersion: "0.12.0",
daemonVersion: "0.11.4",
supportsSelfUpdate: true,
desktopManaged: false,
terminalsSurviveRestart: true,
isConnected: true,
update: { status: "idle" },
...overrides,
};
}
describe("resolveDaemonOutdatedCallout", () => {
it("shows an update notice when the daemon is older than the app", () => {
expect(resolveDaemonOutdatedCallout(input())).toEqual({
id: "daemon-outdated",
dismissalKey: "daemon-outdated:srv_alpha:0.11.4",
priority: 250,
title: "Daemon out of date",
description: [
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon. Terminals stay open.",
],
variant: "default",
updateAction: { label: "Update", disabled: false },
testID: "daemon-outdated-callout",
});
});
it("hides when the daemon matches or is newer than the app", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.1" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "1.0.0" }))).toBeNull();
});
it("compares numeric components numerically, not as strings", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.10.0", daemonVersion: "0.9.9" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.9.9", daemonVersion: "0.10.0" })),
).toBeNull();
});
it("orders prereleases before their release", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0", daemonVersion: "0.12.0-beta.1" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-beta.1", daemonVersion: "0.12.0" })),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.10", daemonVersion: "0.12.0-beta.2" }),
),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.2", daemonVersion: "0.12.0-beta.2" }),
),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-rc.1", daemonVersion: "0.11.9" })),
).not.toBeNull();
});
it("hides when either version is missing or unparseable", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "dev" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: "unknown" }))).toBeNull();
});
it("offers no button for a desktop-managed daemon and points to Paseo on that computer", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ desktopManaged: true }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Paseo Desktop manages this daemon. Update Paseo on that computer.",
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
]);
});
it("never promises terminals survive a desktop update, which closes them on Windows", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: true }),
);
expect(descriptor?.description).not.toContain(
"Updating restarts the daemon. Terminals stay open.",
);
expect(descriptor?.description.at(-1)).toContain("closes them on Windows");
});
it("says a desktop update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: false }),
);
expect(descriptor?.description.at(-1)).toBe(
"Updating restarts the daemon and closes open terminals.",
);
});
it("offers no button when the daemon does not support self-update", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ supportsSelfUpdate: false }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toContain(
"This daemon can't update itself. Update Paseo on that computer.",
);
});
it("says the update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon and closes open terminals.",
]);
});
it("disables the button while the host is disconnected", () => {
expect(resolveDaemonOutdatedCallout(input({ isConnected: false }))?.updateAction).toEqual({
label: "Update",
disabled: true,
});
});
it("shows the update phase on a disabled button while updating", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "updating", phase: "Downloading packages..." } }),
);
expect(descriptor?.variant).toBe("default");
expect(descriptor?.updateAction).toEqual({ label: "Downloading packages...", disabled: true });
});
it("surfaces a failed update as an error with the button available again", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "failed", message: "Failed to update the daemon: boom" } }),
);
expect(descriptor?.title).toBe("Daemon update failed");
expect(descriptor?.variant).toBe("error");
expect(descriptor?.description).toEqual(["Failed to update the daemon: boom"]);
expect(descriptor?.updateAction).toEqual({ label: "Update", disabled: false });
});
it("keys dismissal by host and daemon version", () => {
const key = resolveDaemonOutdatedCallout(input())?.dismissalKey;
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.11.4" }))?.dismissalKey).toBe(
key,
);
expect(resolveDaemonOutdatedCallout(input({ appVersion: "0.13.0" }))?.dismissalKey).toBe(key);
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11.5" }))?.dismissalKey).toBe(
"daemon-outdated:srv_alpha:0.11.5",
);
expect(resolveDaemonOutdatedCallout(input({ serverId: "srv_beta" }))?.dismissalKey).toBe(
"daemon-outdated:srv_beta:0.11.4",
);
});
it("uses the active app language", async () => {
await i18n.changeLanguage("es");
try {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.title).toBe("Daemon desactualizado");
expect(descriptor?.description).toEqual([
"Workstation ejecuta el daemon v0.11.4. Esta app es v0.12.0.",
"Actualizar reinicia el daemon y cierra las terminales abiertas.",
]);
expect(descriptor?.updateAction?.label).toBe("Actualizar");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,147 +0,0 @@
import { i18n } from "@/i18n/i18next";
export type DaemonOutdatedUpdateState =
| { status: "idle" }
| { status: "updating"; phase: string }
| { status: "failed"; message: string };
export interface ResolveDaemonOutdatedCalloutInput {
serverId: string;
hostLabel: string;
appVersion: string | null;
daemonVersion: string | null;
supportsSelfUpdate: boolean;
desktopManaged: boolean;
terminalsSurviveRestart: boolean;
isConnected: boolean;
update: DaemonOutdatedUpdateState;
}
export interface DaemonOutdatedUpdateAction {
label: string;
disabled: boolean;
}
export interface DaemonOutdatedCalloutDescriptor {
id: "daemon-outdated";
dismissalKey: string;
priority: number;
title: string;
description: string[];
variant: "default" | "error";
updateAction: DaemonOutdatedUpdateAction | null;
testID: "daemon-outdated-callout";
}
interface ParsedVersion {
core: [number, number, number];
prerelease: string[];
}
const VERSION_PATTERN =
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
const NUMERIC_IDENTIFIER = /^\d+$/;
function parseVersion(value: string | null): ParsedVersion | null {
const match = value?.trim().match(VERSION_PATTERN);
if (!match) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ? match[4].split(".") : [],
};
}
function compareIdentifiers(left: string, right: string): number {
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
if (isLeftNumeric) return -1;
if (isRightNumeric) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
for (let index = 0; index < 3; index += 1) {
const difference = left.core[index] - right.core[index];
if (difference !== 0) return difference;
}
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
return right.prerelease.length - left.prerelease.length;
}
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < sharedLength; index += 1) {
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
if (difference !== 0) return difference;
}
return left.prerelease.length - right.prerelease.length;
}
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
if (input.update.status === "updating") {
return { label: input.update.phase, disabled: true };
}
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
}
function formatVersion(version: ParsedVersion): string {
const core = version.core.join(".");
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
}
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
// on Windows, which closes them. server_info does not name the host's platform, so the line says
// both.
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
return i18n.t("daemonOutdated.terminalsStayOpen");
}
function resolveDescription(
input: ResolveDaemonOutdatedCalloutInput,
versions: { daemon: ParsedVersion; app: ParsedVersion },
): string[] {
if (input.update.status === "failed") return [input.update.message];
const lines = [
i18n.t("daemonOutdated.versions", {
host: input.hostLabel,
daemonVersion: `v${formatVersion(versions.daemon)}`,
appVersion: `v${formatVersion(versions.app)}`,
}),
];
if (input.desktopManaged) {
lines.push(i18n.t("daemonOutdated.desktopManaged"));
} else if (!input.supportsSelfUpdate) {
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
}
lines.push(resolveTerminalsLine(input));
return lines;
}
export function resolveDaemonOutdatedCallout(
input: ResolveDaemonOutdatedCalloutInput,
): DaemonOutdatedCalloutDescriptor | null {
const app = parseVersion(input.appVersion);
const daemon = parseVersion(input.daemonVersion);
if (!app || !daemon) return null;
if (compareVersions(daemon, app) >= 0) return null;
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
const isFailed = input.update.status === "failed";
return {
id: "daemon-outdated",
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
priority: 250,
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
description: resolveDescription(input, { daemon, app }),
variant: isFailed ? "error" : "default",
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
testID: "daemon-outdated-callout",
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./sidebar-callout-slot.native";
@@ -0,0 +1,3 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
root?.render( root?.render(
<SidebarCalloutProvider> <SidebarCalloutProvider>
<RegisterCallout /> <RegisterCallout />
<SidebarCalloutSlot nativeOnly={false} /> <SidebarCalloutSlot />
</SidebarCalloutProvider>, </SidebarCalloutProvider>,
); );
await Promise.resolve(); await Promise.resolve();
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
await act(async () => { await act(async () => {
root?.render( root?.render(
<SidebarCalloutProvider> <SidebarCalloutProvider>
<SidebarCalloutSlot nativeOnly={false} /> <SidebarCalloutSlot />
</SidebarCalloutProvider>, </SidebarCalloutProvider>,
); );
await Promise.resolve(); await Promise.resolve();
@@ -2,14 +2,10 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context"; import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
/** export function SidebarCalloutSlot() {
* `nativeOnly` limits the slot to callouts marked `native`. Native apps and every compact
* sidebar pass it: the other callouts are about the desktop app.
*/
export function SidebarCalloutSlot({ nativeOnly }: { nativeOnly: boolean }) {
return ( return (
<View style={styles.slot} collapsable={false}> <View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport nativeOnly={nativeOnly} /> <SidebarCalloutViewport />
</View> </View>
); );
} }
@@ -21,7 +21,6 @@ import {
showSidebarCallout, showSidebarCallout,
type SidebarCalloutEntry, type SidebarCalloutEntry,
type SidebarCalloutOptions, type SidebarCalloutOptions,
type SidebarCalloutSelectionState,
type SidebarCalloutState, type SidebarCalloutState,
unregisterSidebarCallout, unregisterSidebarCallout,
} from "./sidebar-callout-state"; } from "./sidebar-callout-state";
@@ -38,7 +37,7 @@ export interface SidebarCalloutsApi {
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null); const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null); const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void { function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem( void AsyncStorage.setItem(
@@ -114,10 +113,11 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}); });
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]); const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return ( return (
<SidebarCalloutApiContext.Provider value={api}> <SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={state}> <SidebarCalloutStateContext.Provider value={activeCallout}>
{children} {children}
</SidebarCalloutStateContext.Provider> </SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider> </SidebarCalloutApiContext.Provider>
@@ -132,20 +132,12 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
return api; return api;
} }
export function useActiveSidebarCallout( export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
options: { nativeOnly?: boolean } = {}, return useContext(SidebarCalloutStateContext);
): SidebarCalloutEntry | null {
const state = useContext(SidebarCalloutStateContext);
const nativeOnly = options.nativeOnly === true;
return useMemo(
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
[nativeOnly, state],
);
} }
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */ export function SidebarCalloutViewport() {
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) { const activeCallout = useActiveSidebarCallout();
const activeCallout = useActiveSidebarCallout({ nativeOnly });
const api = useSidebarCallouts(); const api = useSidebarCallouts();
if (!activeCallout) { if (!activeCallout) {
return null; return null;
@@ -31,30 +31,6 @@ describe("sidebar callout state", () => {
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts"); expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
}); });
it("selects only callouts marked native for the native sidebar", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "desktop-update",
priority: 300,
title: "Update available",
}).state;
state = showSidebarCallout(state, {
id: "daemon-outdated",
priority: 250,
title: "Daemon out of date",
native: true,
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
expect(selectActiveSidebarCallout(state, { nativeOnly: true })?.title).toBe(
"Daemon out of date",
);
state = dismissSidebarCallout(state, "daemon-outdated").state;
expect(selectActiveSidebarCallout(state, { nativeOnly: true })).toBeNull();
});
it("replaces a callout by id without duplicating the queue item", () => { it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState(); let state = createSidebarCalloutState();
state = showSidebarCallout(state, { state = showSidebarCallout(state, {
@@ -14,8 +14,6 @@ export interface SidebarCalloutOptions {
priority?: number; priority?: number;
onDismiss?: () => void; onDismiss?: () => void;
testID?: string; testID?: string;
/** Also shown in the iOS and Android sidebar and every compact sidebar. Callouts about the desktop app leave it unset. */
native?: boolean;
} }
export interface SidebarCalloutEntry extends SidebarCalloutOptions { export interface SidebarCalloutEntry extends SidebarCalloutOptions {
@@ -131,19 +129,10 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
return { ...state, callouts: [] }; return { ...state, callouts: [] };
} }
export type SidebarCalloutSelectionState = Pick<
SidebarCalloutState,
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
>;
export function selectActiveSidebarCallout( export function selectActiveSidebarCallout(
state: SidebarCalloutSelectionState, state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null { ): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => { const visibleCallouts = state.callouts.filter((entry) => {
if (options.nativeOnly && entry.native !== true) {
return false;
}
const dismissalKey = normalizeDismissalKey(entry.dismissalKey); const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) { if (!dismissalKey) {
return true; return true;
-16
View File
@@ -2058,18 +2058,6 @@ export const ar: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "رفض", dismiss: "رفض",
}, },
daemonOutdated: {
title: "الخدمة قديمة",
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
desktopUpdateTerminals:
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
update: "تحديث",
updateFailedTitle: "فشل تحديث الخدمة",
},
contextWindow: { contextWindow: {
noData: "لا توجد بيانات للسياق", noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق", accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
@@ -2885,10 +2873,6 @@ export const ar: TranslationResources = {
requestFailedTitle: "خطأ", requestFailedTitle: "خطأ",
requestFailedMessage: requestFailedMessage:
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.", "فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
closeTerminals: "إعادة التشغيل وإغلاق الطرفيات",
closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.",
closeTerminalsConfirmMessage:
"سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
}, },
update: { update: {
-16
View File
@@ -2083,18 +2083,6 @@ export const en = {
sidebarCallout: { sidebarCallout: {
dismiss: "Dismiss", dismiss: "Dismiss",
}, },
daemonOutdated: {
title: "Daemon out of date",
versions: "{{host}} runs daemon {{daemonVersion}}. This app is {{appVersion}}.",
terminalsStayOpen: "Updating restarts the daemon. Terminals stay open.",
terminalsClose: "Updating restarts the daemon and closes open terminals.",
desktopUpdateTerminals:
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
desktopManaged: "Paseo Desktop manages this daemon. Update Paseo on that computer.",
selfUpdateUnsupported: "This daemon can't update itself. Update Paseo on that computer.",
update: "Update",
updateFailedTitle: "Daemon update failed",
},
contextWindow: { contextWindow: {
noData: "No context data", noData: "No context data",
accessibilityNoData: "Context window: No context data", accessibilityNoData: "Context window: No context data",
@@ -2978,10 +2966,6 @@ export const en = {
requestFailedTitle: "Error", requestFailedTitle: "Error",
requestFailedMessage: requestFailedMessage:
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.", "Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
closeTerminals: "Restart and close terminals",
closeTerminalsHint: "Restart and also close every terminal running on this host.",
closeTerminalsConfirmMessage:
"This will restart the daemon and close every terminal on it, ending the programs running in them. The app will reconnect automatically.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.", dialogFailedMessage: "Unable to open the restart confirmation dialog.",
}, },
update: { update: {
-18
View File
@@ -2106,19 +2106,6 @@ export const es: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Despedir", dismiss: "Despedir",
}, },
daemonOutdated: {
title: "Daemon desactualizado",
versions: "{{host}} ejecuta el daemon {{daemonVersion}}. Esta app es {{appVersion}}.",
terminalsStayOpen: "Actualizar reinicia el daemon. Las terminales siguen abiertas.",
terminalsClose: "Actualizar reinicia el daemon y cierra las terminales abiertas.",
desktopUpdateTerminals:
"Actualizar Paseo Desktop mantiene las terminales abiertas en macOS y Linux, y las cierra en Windows.",
desktopManaged: "Paseo Desktop administra este daemon. Actualiza Paseo en esa computadora.",
selfUpdateUnsupported:
"Este daemon no puede actualizarse solo. Actualiza Paseo en esa computadora.",
update: "Actualizar",
updateFailedTitle: "No se pudo actualizar el daemon",
},
contextWindow: { contextWindow: {
noData: "No hay datos de contexto", noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto", accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
@@ -2910,11 +2897,6 @@ export const es: TranslationResources = {
requestFailedTitle: "Error", requestFailedTitle: "Error",
requestFailedMessage: requestFailedMessage:
"No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.", "No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.",
closeTerminals: "Reiniciar y cerrar terminales",
closeTerminalsHint:
"Reinicia y además cierra todas las terminales abiertas en este host.",
closeTerminalsConfirmMessage:
"Esto reiniciará el daemon y cerrará todas sus terminales, terminando los programas que se ejecutan en ellas. La app se reconectará automáticamente.",
dialogFailedMessage: dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.", "No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
}, },
-17
View File
@@ -2105,19 +2105,6 @@ export const fr: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Ignorer", dismiss: "Ignorer",
}, },
daemonOutdated: {
title: "Daemon obsolète",
versions: "{{host}} exécute le daemon {{daemonVersion}}. Cette app est en {{appVersion}}.",
terminalsStayOpen: "La mise à jour redémarre le daemon. Les terminaux restent ouverts.",
terminalsClose: "La mise à jour redémarre le daemon et ferme les terminaux ouverts.",
desktopUpdateTerminals:
"La mise à jour de Paseo Desktop garde les terminaux ouverts sur macOS et Linux, et les ferme sur Windows.",
desktopManaged: "Paseo Desktop gère ce daemon. Mettez à jour Paseo sur cet ordinateur.",
selfUpdateUnsupported:
"Ce daemon ne peut pas se mettre à jour lui-même. Mettez à jour Paseo sur cet ordinateur.",
update: "Mettre à jour",
updateFailedTitle: "Échec de la mise à jour du daemon",
},
contextWindow: { contextWindow: {
noData: "Aucune donnée de contexte", noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
@@ -2915,10 +2902,6 @@ export const fr: TranslationResources = {
requestFailedTitle: "Erreur", requestFailedTitle: "Erreur",
requestFailedMessage: requestFailedMessage:
"Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.",
closeTerminals: "Redémarrer et fermer les terminaux",
closeTerminalsHint: "Redémarre et ferme aussi tous les terminaux ouverts sur cet hôte.",
closeTerminalsConfirmMessage:
"Le daemon va redémarrer et fermer tous ses terminaux, ce qui arrêtera les programmes qui y tournent. L'app se reconnectera automatiquement.",
dialogFailedMessage: dialogFailedMessage:
"Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.",
}, },
-18
View File
@@ -2078,20 +2078,6 @@ export const ja: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "閉じる", dismiss: "閉じる",
}, },
daemonOutdated: {
title: "デーモンが古くなっています",
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
desktopUpdateTerminals:
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
desktopManaged:
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
selfUpdateUnsupported:
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
update: "更新",
updateFailedTitle: "デーモンを更新できませんでした",
},
contextWindow: { contextWindow: {
noData: "コンテキストデータがありません", noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません", accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
@@ -2879,10 +2865,6 @@ export const ja: TranslationResources = {
requestFailedTitle: "エラー", requestFailedTitle: "エラー",
requestFailedMessage: requestFailedMessage:
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。", "再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
closeTerminals: "再起動してターミナルを閉じる",
closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。",
closeTerminalsConfirmMessage:
"デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。", dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
}, },
update: { update: {
-18
View File
@@ -2068,20 +2068,6 @@ export const ko: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "닫기", dismiss: "닫기",
}, },
daemonOutdated: {
title: "데몬이 오래되었습니다",
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
desktopUpdateTerminals:
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
desktopManaged:
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
selfUpdateUnsupported:
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
update: "업데이트",
updateFailedTitle: "데몬 업데이트 실패",
},
contextWindow: { contextWindow: {
noData: "컨텍스트 데이터 없음", noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음", accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
@@ -2866,10 +2852,6 @@ export const ko: TranslationResources = {
requestFailedTitle: "오류", requestFailedTitle: "오류",
requestFailedMessage: requestFailedMessage:
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.", "재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
closeTerminals: "재시작하고 터미널 닫기",
closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.",
closeTerminalsConfirmMessage:
"데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.",
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.", dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
}, },
update: { update: {
-17
View File
@@ -2091,19 +2091,6 @@ export const ptBR: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Dispensar", dismiss: "Dispensar",
}, },
daemonOutdated: {
title: "Daemon desatualizado",
versions: "{{host}} executa o daemon {{daemonVersion}}. Este app é {{appVersion}}.",
terminalsStayOpen: "Atualizar reinicia o daemon. Os terminais continuam abertos.",
terminalsClose: "Atualizar reinicia o daemon e fecha os terminais abertos.",
desktopUpdateTerminals:
"Atualizar o Paseo Desktop mantém os terminais abertos no macOS e no Linux, e os fecha no Windows.",
desktopManaged: "O Paseo Desktop gerencia este daemon. Atualize o Paseo nesse computador.",
selfUpdateUnsupported:
"Este daemon não consegue se atualizar sozinho. Atualize o Paseo nesse computador.",
update: "Atualizar",
updateFailedTitle: "Falha ao atualizar o daemon",
},
contextWindow: { contextWindow: {
noData: "Sem dados de contexto", noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto", accessibilityNoData: "Janela de contexto: sem dados de contexto",
@@ -2893,10 +2880,6 @@ export const ptBR: TranslationResources = {
requestFailedTitle: "Erro", requestFailedTitle: "Erro",
requestFailedMessage: requestFailedMessage:
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.", "Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
closeTerminals: "Reiniciar e fechar terminais",
closeTerminalsHint: "Reinicia e também fecha todos os terminais abertos neste host.",
closeTerminalsConfirmMessage:
"Isso reiniciará o daemon e fechará todos os terminais dele, encerrando os programas que rodam neles. O app vai se reconectar automaticamente.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.", dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
}, },
update: { update: {
-16
View File
@@ -2091,18 +2091,6 @@ export const ru: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Закрыть", dismiss: "Закрыть",
}, },
daemonOutdated: {
title: "Демон устарел",
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
desktopUpdateTerminals:
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
update: "Обновить",
updateFailedTitle: "Не удалось обновить демон",
},
contextWindow: { contextWindow: {
noData: "Нет данных о контексте", noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте", accessibilityNoData: "Контекстное окно: нет данных о контексте",
@@ -2902,10 +2890,6 @@ export const ru: TranslationResources = {
requestFailedTitle: "Ошибка", requestFailedTitle: "Ошибка",
requestFailedMessage: requestFailedMessage:
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.", "Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
closeTerminals: "Перезапустить и закрыть терминалы",
closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.",
closeTerminalsConfirmMessage:
"Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.",
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.", dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
}, },
update: { update: {
-16
View File
@@ -2036,18 +2036,6 @@ export const zhCN: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "关闭", dismiss: "关闭",
}, },
daemonOutdated: {
title: "守护进程版本过旧",
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
desktopUpdateTerminals:
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
update: "更新",
updateFailedTitle: "守护进程更新失败",
},
contextWindow: { contextWindow: {
noData: "暂无上下文数据", noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据", accessibilityNoData: "上下文窗口:暂无上下文数据",
@@ -2816,10 +2804,6 @@ export const zhCN: TranslationResources = {
offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。", offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。",
requestFailedTitle: "错误", requestFailedTitle: "错误",
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。", requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
closeTerminals: "重启并关闭终端",
closeTerminalsHint: "重启的同时关闭此主机上的所有终端。",
closeTerminalsConfirmMessage:
"这将重启守护进程并关闭其所有终端,终端中运行的程序会被结束。应用会自动重新连接。",
dialogFailedMessage: "无法打开重启确认对话框。", dialogFailedMessage: "无法打开重启确认对话框。",
}, },
update: { update: {
@@ -12,69 +12,6 @@ test("settings restart completes when a replacement worker is observed without a
expect(pid).toBe(11); expect(pid).toBe(11);
}); });
test("settings restart keeps terminals by default and closes them only when asked", async () => {
let pid = 10;
const requests: Array<{ killTerminals: boolean }> = [];
const deps = {
getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }),
restartServer: async (_reason: string, options: { killTerminals: boolean }) => {
requests.push(options);
pid += 1;
},
};
await restartDaemonFromSettings("daemon", "settings", deps);
await restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true });
expect(requests).toEqual([{ killTerminals: false }, { killTerminals: true }]);
});
test("a close-terminals restart completes when the replacement reports every terminal closed", async () => {
let pid = 10;
await restartDaemonFromSettings(
"daemon",
"settings",
{
getStatus: async () => ({
pid,
version: "1.0.0",
serverId: "daemon",
terminalCleanupUnconfirmed: [],
}),
restartServer: async () => {
pid = 11;
},
},
{ killTerminals: true },
);
expect(pid).toBe(11);
});
test("a close-terminals restart fails with the processes the replacement could not confirm closed", async () => {
let pid = 10;
const leftover = {
pid: 4242,
role: "terminal program",
reason: "still alive",
detail: "survived SIGKILL",
};
const deps = {
getStatus: async () => ({
pid,
version: "1.0.0",
serverId: "daemon",
terminalCleanupUnconfirmed: pid === 10 ? [] : [leftover],
}),
restartServer: async () => {
pid += 1;
},
};
await expect(
restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }),
).rejects.toThrow("pid 4242 (terminal program): still alive (survived SIGKILL)");
// Keeping terminals asks nothing of the cleanup, so a leftover does not fail it.
await restartDaemonFromSettings("daemon", "settings", deps);
});
test("a status permission failure prevents the restart request", async () => { test("a status permission failure prevents the restart request", async () => {
let restarted = false; let restarted = false;
await expect( await expect(
@@ -1,61 +1,35 @@
interface UnconfirmedTerminalProcess {
pid: number;
role: string;
reason: string;
detail: string;
}
interface WorkerStatus { interface WorkerStatus {
pid: number; pid: number;
serverId: string; serverId: string;
version: string | null; version: string | null;
// Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup to report.
terminalCleanupUnconfirmed?: UnconfirmedTerminalProcess[];
} }
interface StatusReader { interface StatusReader {
getStatus: () => Promise<WorkerStatus>; getStatus: () => Promise<WorkerStatus>;
} }
export interface SettingsDaemonRestartDeps extends StatusReader { export interface SettingsDaemonRestartDeps extends StatusReader {
restartServer: (reason: string, options: { killTerminals: boolean }) => Promise<unknown>; restartServer: (reason: string) => Promise<unknown>;
} }
/** /** Acknowledgment starts the wait; only a different ready worker completes it. */
* Acknowledgment starts the wait; only a different ready worker completes it. Terminals survive
* the restart unless `killTerminals` is set (daemons advertising terminalsSurviveRestart); then
* the replacement must also report every terminal process gone.
*/
export async function restartDaemonFromSettings( export async function restartDaemonFromSettings(
hostServerId: string, hostServerId: string,
reason: string, reason: string,
deps: SettingsDaemonRestartDeps, deps: SettingsDaemonRestartDeps,
options: { killTerminals: boolean } = { killTerminals: false },
): Promise<void> { ): Promise<void> {
const previous = await readSelectedWorker(hostServerId, deps); const previous = await readSelectedWorker(hostServerId, deps);
let acknowledged = false; let acknowledged = false;
try { try {
await deps.restartServer(reason, options); await deps.restartServer(reason);
acknowledged = true; acknowledged = true;
} catch (error) { } catch (error) {
if (!isReconnectFailure(error)) throw error; if (!isReconnectFailure(error)) throw error;
} }
let replacement: WorkerStatus;
try { try {
replacement = await observeReplacement(previous, deps); await observeReplacement(previous, deps);
} catch (error) { } catch (error) {
throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error });
} }
const unconfirmed = replacement.terminalCleanupUnconfirmed ?? [];
if (options.killTerminals && unconfirmed.length > 0) {
throw new Error(
[
"The daemon restarted, but closing its terminals was not confirmed; these processes may still be running:",
...unconfirmed.map(
(entry) => `pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`,
),
"Stopping the daemon retries the cleanup.",
].join("\n"),
);
}
} }
export async function updateDaemonFromSettings( export async function updateDaemonFromSettings(
+61 -107
View File
@@ -565,15 +565,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
const isConnected = useHostRuntimeIsConnected(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
// Rendered in the card, not an Alert.alert: react-native-web's Alert is a no-op, and a failed
// close-terminals restart must name the processes left running until the user retries.
const [restartError, setRestartError] = useState<string | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
const terminalsSurviveRestart = useSessionStore(
(state) =>
state.sessions[host.serverId]?.serverInfo?.features?.terminalsSurviveRestart === true,
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -586,80 +578,71 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
[host.serverId, runtime], [host.serverId, runtime],
); );
const waitForDaemonRestart = useCallback(async (request: Promise<void>) => { const waitForDaemonRestart = useCallback(
try { async (request: Promise<void>) => {
await request; try {
} catch (error) { await request;
console.error("[HostPage] Daemon restart failed", error); } catch (error) {
if (isMountedRef.current) {
setRestartError(error instanceof Error ? error.message : String(error));
}
} finally {
if (isMountedRef.current) setIsRestarting(false);
}
}, []);
const startRestart = useCallback(
(killTerminals: boolean) => {
if (!daemonClient) {
Alert.alert( Alert.alert(
t("settings.host.daemon.restart.unavailableTitle"), t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.unavailableMessage"), error instanceof Error ? error.message : String(error),
); );
return; } finally {
if (isMountedRef.current) setIsRestarting(false);
} }
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.restart.offlineTitle"),
t("settings.host.daemon.restart.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
message: killTerminals
? t("settings.host.daemon.restart.closeTerminalsConfirmMessage")
: t("settings.host.daemon.restart.confirmMessage"),
confirmLabel: killTerminals
? t("settings.host.daemon.restart.closeTerminals")
: t("settings.host.daemon.restart.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return;
setRestartError(null);
setIsRestarting(true);
const restartRequest = restartDaemonFromSettings(
host.serverId,
`settings_daemon_restart_${host.serverId}`,
{
restartServer: (reason, options) =>
daemonClient.restartServer(reason, undefined, options),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
},
{ killTerminals },
);
void waitForDaemonRestart(restartRequest);
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
if (isMountedRef.current) {
setRestartError(t("settings.host.daemon.restart.dialogFailedMessage"));
}
});
}, },
[daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart], [t],
); );
const handleRestart = useCallback(() => startRestart(false), [startRestart]); const handleRestart = useCallback(() => {
const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]); if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.restart.unavailableTitle"),
t("settings.host.daemon.restart.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.restart.offlineTitle"),
t("settings.host.daemon.restart.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.restart.confirmMessage"),
confirmLabel: t("settings.host.daemon.restart.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return;
setIsRestarting(true);
const restartRequest = restartDaemonFromSettings(
host.serverId,
`settings_daemon_restart_${host.serverId}`,
{
restartServer: (reason) => daemonClient.restartServer(reason),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
},
);
void waitForDaemonRestart(restartRequest);
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.restart.requestFailedTitle"),
t("settings.host.daemon.restart.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]);
return ( return (
<View style={settingsStyles.card} testID="host-page-restart-card"> <View style={settingsStyles.card} testID="host-page-restart-card">
@@ -681,35 +664,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
: t("settings.host.daemon.restart.confirm")} : t("settings.host.daemon.restart.confirm")}
</Button> </Button>
</View> </View>
{terminalsSurviveRestart ? (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowHint}>
{t("settings.host.daemon.restart.closeTerminalsHint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleRestartAndCloseTerminals}
disabled={isRestarting || !daemonClient || !isConnected}
testID="host-page-restart-close-terminals-button"
>
{t("settings.host.daemon.restart.closeTerminals")}
</Button>
</View>
) : null}
{restartError ? (
<View style={styles.cardAlert}>
<InlineAlert
size="sm"
variant="error"
title={t("settings.host.daemon.restart.requestFailedTitle")}
description={restartError}
testID="host-page-restart-error"
/>
</View>
) : null}
</View> </View>
); );
} }
@@ -894,7 +848,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
/> />
) : null} ) : null}
{updateState.status === "failed" ? ( {updateState.status === "failed" ? (
<View style={styles.cardAlert}> <View style={styles.updateFailure}>
<InlineAlert <InlineAlert
size="sm" size="sm"
variant="error" variant="error"
@@ -1748,7 +1702,7 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
})); }));
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
cardAlert: { updateFailure: {
marginHorizontal: theme.spacing[4], marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4], marginBottom: theme.spacing[4],
}, },
@@ -112,7 +112,7 @@ function appendRun<TGroup>(input: {
const run = createRun(input.calls, input.isSealed); const run = createRun(input.calls, input.isSealed);
const host = createHost(run); const host = createHost(run);
input.output.push(host); input.output.push(host);
if (run.calls.length > 1) input.groups.set(host.id, input.buildGroup(run)); input.groups.set(host.id, input.buildGroup(run));
} }
export function prepareGroupedHistory<TGroup>(input: { export function prepareGroupedHistory<TGroup>(input: {
@@ -180,8 +180,7 @@ export function groupLiveToolCalls<TGroup>(input: {
if (hostPlacement === "head") { if (hostPlacement === "head") {
head.push(createHost(run)); head.push(createHost(run));
} }
const needsLiveGroup = hostPlacement === "head" || pendingIncludesHead || !isSealed; if (hostPlacement === "head" || pendingIncludesHead || !isSealed) {
if (run.calls.length > 1 && needsLiveGroup) {
liveGroups.set(run.id, input.buildGroup(run)); liveGroups.set(run.id, input.buildGroup(run));
} }
pending = []; pending = [];
@@ -218,13 +217,19 @@ export function groupLiveToolCalls<TGroup>(input: {
historyGroupUpdatesByHostId: EMPTY_GROUPS, 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); const groupsByHostId = new Map(input.history.groupsByHostId);
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null; let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
for (const [id, group] of liveGroups) { for (const [id, group] of liveGroups) {
groupsByHostId.set(id, group); groupsByHostId.set(id, group);
const isHistoryHost = if (input.history.groupsByHostId.has(id)) {
input.history.groupsByHostId.has(id) || input.history.pendingCalls[0]?.id === id;
if (isHistoryHost) {
historyGroupUpdatesByHostId ??= new Map(); historyGroupUpdatesByHostId ??= new Map();
historyGroupUpdatesByHostId.set(id, group); historyGroupUpdatesByHostId.set(id, group);
} }
@@ -4,12 +4,8 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho
import { Wrench, X } from "lucide-react-native"; import { Wrench, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal";
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet"; import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet";
import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
interface OverviewToolCallGroupSheetProps { interface OverviewToolCallGroupSheetProps {
@@ -33,12 +29,7 @@ export function OverviewToolCallGroupSheet({
}: OverviewToolCallGroupSheetProps) { }: OverviewToolCallGroupSheetProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<BottomSheetScrollViewMethods>(null); const scrollRef = useRef<BottomSheetScrollViewMethods>(null);
const sheetContextBridge = useToolCallSheetContextBridge(); const contextBridge = useToolCallSheetContextBridge();
const expansionContextBridge = useToolCallExpansionContextBridge();
const contextBridge = useCallback<ContextBridge>(
(content) => sheetContextBridge(expansionContextBridge(content)),
[expansionContextBridge, sheetContextBridge],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible, visible,
onClose, onClose,
@@ -110,7 +110,11 @@ describe("tool call detail-level projection", () => {
preparedHistory: prepared, preparedHistory: prepared,
}); });
expect(single.head).toEqual([firstCall]); expect(single.head).toEqual([firstCall]);
expect(single.groupsByHostId.size).toBe(0); expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
calls: [firstCall],
latest: firstCall,
isSealed: false,
});
const grouped = project({ const grouped = project({
level: "overview", level: "overview",
@@ -138,11 +142,18 @@ describe("tool call detail-level projection", () => {
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
}); });
it("leaves a running lone call visible without an aggregate", () => { it("builds a loading aggregate for a one-call run", () => {
const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" });
const result = project({ level: "overview", head: [call], isTurnActive: true }); const result = project({ level: "overview", head: [call], isTurnActive: true });
expect(result.head).toEqual([call]); const group = result.groupsByHostId.get(call.id);
expect(result.groupsByHostId.size).toBe(0); if (!group) {
throw new Error("Expected an overview group");
}
expect(group).toMatchObject({
isLoading: true,
summary: { commandCount: 1 },
});
}); });
it("keeps an active overview group on its latest call until a visible boundary arrives", () => { it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
@@ -470,7 +481,8 @@ describe("tool call detail-level projection", () => {
const result = project({ level: "overview", head: [singleCall, plan, speak] }); const result = project({ level: "overview", head: [singleCall, plan, speak] });
expect(result.head).toEqual([singleCall, plan, speak]); expect(result.head).toEqual([singleCall, plan, speak]);
expect(result.groupsByHostId.size).toBe(0); expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]);
expect(result.groupsByHostId.size).toBe(1);
}); });
}); });
@@ -517,58 +529,12 @@ it.each([false, true])(
}); });
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual(
autoExpandReasoning ? undefined : [call, thought], autoExpandReasoning ? [call] : [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);
});
@@ -1,69 +0,0 @@
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<ToolCallExpansion | null>(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) => (
<ToolCallExpansionContext.Provider value={expansion}>
{children}
</ToolCallExpansionContext.Provider>
),
[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 };
}
@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import { createToolCallExpansion } from "./expansion";
function observe(expansion: ReturnType<typeof createToolCallExpansion>) {
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);
});
});
-67
View File
@@ -1,67 +0,0 @@
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<string, boolean>();
const callListeners = new Map<string, Set<Listener>>();
const groups = new Set<string>();
const groupListeners = new Set<Listener>();
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<typeof createToolCallExpansion>;
+1 -25
View File
@@ -3,11 +3,8 @@ import {
readDaemonInstance, readDaemonInstance,
isSameDaemonInstance, isSameDaemonInstance,
DaemonInstanceError, DaemonInstanceError,
retryUnconfirmedTerminalCleanup,
TerminalCleanupNotConfirmedError,
} from "@getpaseo/server/daemon-control"; } from "@getpaseo/server/daemon-control";
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import type { DaemonGetStatusResponse } from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js"; import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
@@ -23,30 +20,13 @@ export function daemonRestartCommand(): Command {
), ),
) )
.option("--timeout <seconds>", "Replacement readiness deadline (default: 600)") .option("--timeout <seconds>", "Replacement readiness deadline (default: 600)")
.option("--kill-terminals", "Close every terminal instead of keeping them across the restart")
.action(withOutput(runRestartCommand)); .action(withOutput(runRestartCommand));
} }
type DaemonStatus = DaemonGetStatusResponse["payload"];
export async function runRestartCommand(options: CommandOptions, _command: Command) { export async function runRestartCommand(options: CommandOptions, _command: Command) {
const target = options.daemonTarget; const target = options.daemonTarget;
const deadline = Date.now() + parseTimeoutMs(options.timeout); const deadline = Date.now() + parseTimeoutMs(options.timeout);
const remaining = () => Math.max(1, deadline - Date.now()); 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; const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null;
async function checkSupervisor() { async function checkSupervisor() {
if (target.kind !== "instance") return; if (target.kind !== "instance") return;
@@ -74,10 +54,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid;
await checkSupervisor(); await checkSupervisor();
try { try {
await client.restartServer("cli_restart", undefined, { await client.restartServer("cli_restart", undefined, { timeout: remaining() });
timeout: remaining(),
killTerminals,
});
acknowledged = true; acknowledged = true;
} catch (error) { } catch (error) {
if (!isReconnectFailure(error)) throw error; if (!isReconnectFailure(error)) throw error;
@@ -100,7 +77,6 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
const status = await replacement.getDaemonStatus({ timeout: remaining() }); const status = await replacement.getDaemonStatus({ timeout: remaining() });
if (status.pid !== workerPid) { if (status.pid !== workerPid) {
await checkSupervisor(); await checkSupervisor();
await confirmTerminalsClosed(status);
return { return {
type: "single" as const, type: "single" as const,
data: { data: {
@@ -103,7 +103,6 @@ async function probeDaemonStatus(
daemonNode: status.nodePath, daemonNode: status.nodePath,
providers: status.providers, providers: status.providers,
relay: status.relay, relay: status.relay,
...(status.terminalsUnavailable ? { terminals: status.terminalsUnavailable } : {}),
}; };
} }
} finally { } finally {
@@ -1,110 +0,0 @@
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<string, unknown>, 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([]);
});
});
+4 -38
View File
@@ -1,10 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import {
requireTerminalKeepCapability,
type DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control"; import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control";
import { connectToDaemon, type ConnectOptions } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
import { withOutput, type CommandOptions } from "../../output/index.js"; import { withOutput, type CommandOptions } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { describeDaemonTarget } from "../../utils/daemon-target.js"; import { describeDaemonTarget } from "../../utils/daemon-target.js";
@@ -14,43 +10,19 @@ export function daemonStopCommand(): Command {
return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon"))
.option("--timeout <seconds>", "Graceful exit deadline (default: 15)") .option("--timeout <seconds>", "Graceful exit deadline (default: 15)")
.option("--force", "Permit forced local process cleanup") .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 <seconds>", "Forced exit deadline (default: 3)") .option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)")
.action(withOutput(runStopCommand)); .action(withOutput(runStopCommand));
} }
// The daemon connection stop needs; stop.test.ts passes an in-memory adapter. export async function runStopCommand(options: CommandOptions, _command: Command) {
export type StopDaemonConnection = Pick<
DaemonClient,
"getLastServerInfoMessage" | "shutdownServer" | "close"
>;
export interface StopCommandDeps {
connectToDaemon(options: ConnectOptions): Promise<StopDaemonConnection>;
}
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 target = options.daemonTarget;
const timeoutMs = parseTimeoutMs(options.timeout, 15_000); const timeoutMs = parseTimeoutMs(options.timeout, 15_000);
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const remaining = () => Math.max(1, deadline - Date.now()); const remaining = () => Math.max(1, deadline - Date.now());
const keepTerminals = options.keepTerminals === true;
const requestShutdown = async (instance?: DaemonInstance) => { const requestShutdown = async (instance?: DaemonInstance) => {
const client = await deps.connectToDaemon({ target, timeout: remaining(), instance }); const client = await connectToDaemon({ target, timeout: remaining(), instance });
try { try {
// Checked on the connection that carries the shutdown: a daemon without the capability await client.shutdownServer({ timeout: remaining() });
// would close the terminals it was asked to keep.
if (keepTerminals) requireTerminalKeepCapability(client, describeDaemonTarget(target));
await client.shutdownServer({ timeout: remaining(), keepTerminals });
} finally { } finally {
await client.close(); await client.close();
} }
@@ -60,11 +32,6 @@ async function runStop(options: CommandOptions, deps: StopCommandDeps) {
code: "INVALID_OPTIONS", code: "INVALID_OPTIONS",
message: "--force requires a local --home; an endpoint gives no remote process authority.", 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: { const result: {
action: string; action: string;
home?: string; home?: string;
@@ -77,7 +44,6 @@ async function runStop(options: CommandOptions, deps: StopCommandDeps) {
? { ? {
...(await stopDaemonInstance(target.home, { ...(await stopDaemonInstance(target.home, {
force: options.force === true, force: options.force === true,
keepTerminals,
timeoutMs, timeoutMs,
killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000),
requestShutdown, requestShutdown,
@@ -1,324 +0,0 @@
#!/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<T>(
probe: () => Promise<T | null | false> | T | null | false,
message: string,
timeoutMs = 30_000,
): Promise<T> {
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<T>(args: string[]): Promise<T> {
return JSON.parse((await paseo([...args, "--json"])).stdout) as T;
}
async function capture(terminalId: string): Promise<string> {
const { lines } = await json<{ lines: string[] }>(["terminal", "capture", terminalId]);
return lines.join("\n");
}
async function waitForScreen(terminalId: string, text: string): Promise<void> {
await waitFor(
async () => (await capture(terminalId)).includes(text),
`terminal ${terminalId} never showed ${text}:\n${await capture(terminalId)}`,
);
}
async function terminalIds(): Promise<string[]> {
return (await json<Array<{ id: string }>>(["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<number>((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<string> {
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<number> {
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<void> {
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 ===");
@@ -1,527 +0,0 @@
#!/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<string>): 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<string, unknown>;
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<T>(
probe: () => Promise<T | null | false> | T | null | false,
message: () => string | Promise<string>,
timeoutMs = 30_000,
): Promise<T> {
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<string, string> = {},
): Promise<RunResult> {
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<string, string> = {},
): Promise<RunResult> {
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<T>(installation: Installation, args: string[]): Promise<T> {
return JSON.parse((await paseo(installation, [...args, "--json"])).stdout) as T;
}
async function capture(installation: Installation, terminalId: string): Promise<string> {
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<string> {
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<string> {
return new Promise((resolve) => {
execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], (_error, stdout) =>
resolve(stdout.trim()),
);
});
}
async function terminalIds(installation: Installation): Promise<string[]> {
return (await json<Array<{ id: string }>>(installation, ["terminal", "ls", "--all"])).map(
(terminal) => terminal.id,
);
}
async function startDaemon(installation: Installation, extraEnv: Record<string, string> = {}) {
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 ===");
+1 -36
View File
@@ -781,9 +781,6 @@ type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayl
export interface ShutdownServerOptions { export interface ShutdownServerOptions {
requestId?: string; requestId?: string;
timeout?: number; timeout?: number;
// Leave terminals running for the next daemon. Only daemons advertising
// `features.terminalsSurviveRestart` honor it; check before sending.
keepTerminals?: boolean;
} }
export interface DaemonStatusOptions { export interface DaemonStatusOptions {
requestId?: string; requestId?: string;
@@ -1289,36 +1286,6 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features
return features?.usageSources === true || features?.providerUsageList === true; return features?.usageSources === true || features?.providerUsageList === true;
} }
export class TerminalKeepUnsupportedError extends Error {
readonly code = "TERMINALS_KEEP_UNSUPPORTED";
// Own fields reach the CLI's JSON error output, which desktop reads.
readonly version: string | null;
constructor(target: string, version: string | null) {
super(
`The daemon at ${target}${version ? ` (v${version})` : ""} closes terminals when it stops. Update it first, or run \`paseo daemon stop\` to stop it and close them.`,
);
this.name = "TerminalKeepUnsupportedError";
this.version = version;
}
}
/**
* Gate for an explicit "keep terminals" stop. Older daemons drop `keepTerminals` and close the
* terminals anyway, so read the capability on the connection that will carry the shutdown and
* refuse before sending it.
*/
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
export function requireTerminalKeepCapability(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
target: string,
): void {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.features?.terminalsSurviveRestart !== true) {
throw new TerminalKeepUnsupportedError(target, serverInfo?.version ?? null);
}
}
function buildFetchAgentsRequest( function buildFetchAgentsRequest(
options: FetchAgentsOptions | undefined, options: FetchAgentsOptions | undefined,
requestId: string, requestId: string,
@@ -4104,14 +4071,13 @@ export class DaemonClient {
async restartServer( async restartServer(
reason?: string, reason?: string,
requestId?: string, requestId?: string,
options?: { timeout?: number; killTerminals?: boolean }, options?: { timeout?: number },
): Promise<RestartRequestedStatusPayload> { ): Promise<RestartRequestedStatusPayload> {
const resolvedRequestId = this.createRequestId(requestId); const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "restart_server_request", type: "restart_server_request",
...(reason && reason.trim().length > 0 ? { reason } : {}), ...(reason && reason.trim().length > 0 ? { reason } : {}),
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(options?.killTerminals ? { killTerminals: true } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -4139,7 +4105,6 @@ export class DaemonClient {
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "shutdown_server_request", type: "shutdown_server_request",
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(options?.keepTerminals ? { keepTerminals: true } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
+1 -29
View File
@@ -4,31 +4,6 @@ import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js";
import { createNodeEntrypointInvocation } from "../runtime-paths.js"; import { createNodeEntrypointInvocation } from "../runtime-paths.js";
import { resolveExternalCliEntrypoint } from "./entrypoints.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<string, unknown> | null;
constructor(message: string, error: Record<string, unknown> | 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<string, unknown> | null {
try {
const parsed = JSON.parse(stderr) as { error?: unknown };
return typeof parsed.error === "object" && parsed.error !== null
? (parsed.error as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation { function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation {
return createNodeEntrypointInvocation({ return createNodeEntrypointInvocation({
entrypoint: resolveExternalCliEntrypoint(), entrypoint: resolveExternalCliEntrypoint(),
@@ -110,10 +85,7 @@ export async function runExternalCliJsonCommand(args: string[]): Promise<unknown
stderr: stderr.slice(0, 500), stderr: stderr.slice(0, 500),
command: invocation.command, command: invocation.command,
}); });
throw new ExternalCliCommandError( throw new Error(externalCliFailureMessage(result.exitCode, stdout, stderr));
externalCliFailureMessage(result.exitCode, stdout, stderr),
parseJsonCliError(stderr),
);
} }
const stdout = result.stdout.trim(); const stdout = result.stdout.trim();
@@ -4,13 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { ExternalCliCommandError } from "./cli/external"; import { createDaemonCommandHandlers } from "./daemon-manager";
import {
createDaemonCommandHandlers,
keepsTerminalsOnStop,
stopCapturedDaemon,
type DesktopDaemonStopReason,
} from "./daemon-manager";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
paseoHome: "", paseoHome: "",
@@ -79,8 +73,7 @@ vi.mock("./runtime-paths.js", () => ({
})), })),
})); }));
vi.mock("./cli/external.js", async (importOriginal) => ({ vi.mock("./cli/external.js", () => ({
...(await importOriginal<Record<string, unknown>>()),
runExternalCliJsonCommand: mocks.runExternalCliJsonCommand, runExternalCliJsonCommand: mocks.runExternalCliJsonCommand,
runExternalCliTextCommand: mocks.runExternalCliTextCommand, runExternalCliTextCommand: mocks.runExternalCliTextCommand,
})); }));
@@ -183,107 +176,3 @@ describe("daemon-manager commands", () => {
expect(await handler({ listen: "localhost:6799" })).toBeNull(); 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<unknown>) {
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<void>;
},
) => {
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<Parameters<typeof stopCapturedDaemon>[0]["stopInstance"]>;
return {
stops,
logged,
input: {
home: "/tmp/paseo-home",
instance,
stopInstance,
runCliJson,
log: (message: string) => logged.push(message),
} as Omit<Parameters<typeof stopCapturedDaemon>[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 }]);
});
});
+8 -78
View File
@@ -37,11 +37,7 @@ import {
closeLocalTransportSession, closeLocalTransportSession,
} from "./local-transport.js"; } from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
ExternalCliCommandError,
runExternalCliJsonCommand,
runExternalCliTextCommand,
} from "./cli/external.js";
import { import {
createDesktopSettingsCommandHandlers, createDesktopSettingsCommandHandlers,
type DesktopCommandHandler, type DesktopCommandHandler,
@@ -352,84 +348,18 @@ export async function stopDesktopDaemon(
"Daemon changed since confirmation; inspect its current home and PID before stopping it.", "Daemon changed since confirmation; inspect its current home and PID before stopping it.",
); );
if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus();
const keepTerminals = keepsTerminalsOnStop(reason, process.platform); logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned });
logDesktopDaemonLifecycle("stopping captured supervisor", { await stopDaemonInstance(home, {
reason, instance,
pid: instance.pid, timeoutMs: 15_000,
owned, requestShutdown: async (ready) => {
keepTerminals, await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]);
},
}); });
await stopCapturedDaemon({ home, instance, reason, keepTerminals });
if (owned) ownedLaunch = null; if (owned) ownedLaunch = null;
return resolveDesktopDaemonStatus(); 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<void> {
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<DesktopDaemonStatus> { async function restartDaemon(): Promise<DesktopDaemonStatus> {
await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]);
return resolveDesktopDaemonStatus(); return resolveDesktopDaemonStatus();
-5
View File
@@ -52,11 +52,6 @@
"types": "./dist/client/index.d.ts", "types": "./dist/client/index.d.ts",
"source": "./src/client/index.ts", "source": "./src/client/index.ts",
"default": "./dist/client/index.js" "default": "./dist/client/index.js"
},
"./internal/lifecycle": {
"types": "./dist/server/lifecycle.d.ts",
"source": "./src/server/lifecycle.ts",
"default": "./dist/server/lifecycle.js"
} }
}, },
"publishConfig": { "publishConfig": {
+7 -61
View File
@@ -13,7 +13,6 @@ const entries = {
"./server/provider": "server", "./server/provider": "server",
"./server/usage": "server", "./server/usage": "server",
"./server/acp": "server", "./server/acp": "server",
"./internal/lifecycle": "server",
"./client": "client", "./client": "client",
"./client/host": "client", "./client/host": "client",
"./client/ui": "client", "./client/ui": "client",
@@ -135,8 +134,12 @@ describe("plugin SDK import boundaries", () => {
it.each(Object.entries(entries))( it.each(Object.entries(entries))(
"%s respects its %s boundary, including type dependencies", "%s respects its %s boundary, including type dependencies",
(specifier, runtime) => { (specifier, runtime) => {
const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source); const name =
expect(boundaryViolations(file, runtime)).toEqual([]); specifier === "."
? "index"
: specifier.slice(2) +
(specifier === "./client" || specifier === "./server" ? "/index" : "");
expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]);
}, },
); );
}); });
@@ -167,7 +170,6 @@ describe("plugin example import boundaries", () => {
const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries;
if ( if (
entry === "./client/host" || entry === "./client/host" ||
entry.startsWith("./internal/") ||
!(entry in entries) || !(entry in entries) ||
(entries[entry] !== "shared" && entries[entry] !== runtime) (entries[entry] !== "shared" && entries[entry] !== runtime)
) )
@@ -231,7 +233,7 @@ describe("plugin agent tools SDK boundary", () => {
}, },
}); });
export default function contribute(server: PluginServerContext) { export default function contribute(server: PluginServerContext) {
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
const title: string | null = request.title; const title: string | null = request.title;
// @ts-expect-error labels are read-only // @ts-expect-error labels are read-only
request.labels.x = "changed"; request.labels.x = "changed";
@@ -249,62 +251,6 @@ 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", () => { describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => { it("exposes reload separately from snapshot refresh", () => {
expect( expect(
-7
View File
@@ -13,15 +13,8 @@ export type {
PluginSettingsState, PluginSettingsState,
} from "./contracts.js"; } from "./contracts.js";
export type { export type {
PluginHooks,
PluginHookEvents,
PluginHookRequests,
PluginHookPayload,
PluginHookResult,
PluginHookContext, PluginHookContext,
PluginHookWorkspace, PluginHookWorkspace,
PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent, PluginHookAgent,
PluginSessionOpenRequest, PluginSessionOpenRequest,
PluginTurnOutcome, PluginTurnOutcome,
+14 -153
View File
@@ -21,46 +21,6 @@ export interface PluginHookWorkspace {
archivedAt: string | null; 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 { export interface PluginHookAgent {
id: string; id: string;
workspaceId: string | null; workspaceId: string | null;
@@ -89,9 +49,9 @@ export type PluginTurnOutcome =
| { kind: "failed"; error: { message: string; code?: string } } | { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string }; | { kind: "canceled"; reason: string };
export interface PluginHookEvents { export interface PluginLifecycleEvents {
"agent.turn.before": { agent: PluginHookAgent; turnId: string | null }; "agent.turn_started": { agent: PluginHookAgent; turnId: string | null };
"agent.turn.after": { "agent.turn_ended": {
agent: PluginHookAgent; agent: PluginHookAgent;
turnId: string | null; turnId: string | null;
outcome: PluginTurnOutcome; outcome: PluginTurnOutcome;
@@ -101,58 +61,26 @@ export interface PluginHookEvents {
*/ */
timeline: readonly AgentTimelineItem[]; timeline: readonly AgentTimelineItem[];
}; };
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest }; "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": { "agent.permission_resolved": {
agent: PluginHookAgent; agent: PluginHookAgent;
requestId: string; requestId: string;
resolution: AgentPermissionResponse; resolution: AgentPermissionResponse;
}; };
"agent.archive.after": { agent: PluginHookAgent; archivedAt: string }; "agent.archived": { agent: PluginHookAgent; archivedAt: string };
"agent.create.after": { agent: PluginHookAgent }; "agent.created": { agent: PluginHookAgent };
"agent.close.after": { agent: PluginHookAgent }; "agent.closed": { agent: PluginHookAgent };
"workspace.create.after": { workspace: PluginHookWorkspace }; "workspace.created": { workspace: PluginHookWorkspace };
"workspace.archive.after": { workspace: PluginHookWorkspace }; "workspace.archived": { workspace: PluginHookWorkspace };
} }
export interface PluginHookRequests { export interface PluginBeforeRequests {
"worktree.create.before": PluginWorktreeCreateRequest; "agent.create": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> }; "agent.session_open": PluginSessionOpenRequest;
"agent.open.before": PluginSessionOpenRequest; "workspace.create": Omit<WorkspaceCreateRequest, "type" | "requestId">;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
} }
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<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request }
? { request: Request }
: PluginHooks[Key] extends { payload: infer Payload }
? Payload
: never;
}[Name];
export type PluginHookResult<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result }
? Result | void
: void;
}[Name];
type HookHandlers = {
[Name in keyof PluginHooks]: (
payload: PluginHookPayload<Name>,
context: PluginHookContext,
) => Promise<PluginHookResult<Name>>;
};
export interface PluginLifecycleRegistration { export interface PluginLifecycleRegistration {
addHook<Name extends keyof PluginHooks>(name: Name, handler: HookHandlers[Name]): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
on<Name extends keyof PluginLifecycleEvents>( on<Name extends keyof PluginLifecycleEvents>(
name: Name, name: Name,
handler: ( handler: (
@@ -160,7 +88,6 @@ export interface PluginLifecycleRegistration {
context: PluginHookContext, context: PluginHookContext,
) => void | Promise<void>, ) => void | Promise<void>,
): () => void; ): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
before<Name extends keyof PluginBeforeRequests>( before<Name extends keyof PluginBeforeRequests>(
name: Name, name: Name,
handler: ( handler: (
@@ -169,69 +96,3 @@ export interface PluginLifecycleRegistration {
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>, ) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
): () => void; ): () => 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<keyof PluginHooks, { waits: boolean }>;
// 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<string>(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);
},
};
}
-24
View File
@@ -1896,17 +1896,11 @@ export const RestartServerRequestMessageSchema = z.object({
type: z.literal("restart_server_request"), type: z.literal("restart_server_request"),
reason: z.string().optional(), reason: z.string().optional(),
requestId: z.string(), 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({ export const ShutdownServerRequestMessageSchema = z.object({
type: z.literal("shutdown_server_request"), type: z.literal("shutdown_server_request"),
requestId: z.string(), 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({ export const DaemonUpdateRequestMessageSchema = z.object({
@@ -3769,8 +3763,6 @@ export const ServerInfoStatusPayloadSchema = z
daemonDiagnostics: z.boolean().optional(), daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
daemonSelfUpdate: z.boolean().optional(), 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. // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(), agentForkContext: z.boolean().optional(),
// COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.
@@ -5133,22 +5125,6 @@ export const DaemonGetStatusResponseSchema = z.object({
error: z.string().nullable().optional(), 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(), .passthrough(),
}); });
+2 -2
View File
@@ -73,7 +73,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs", "clean": "node ../../scripts/clean-package-dist.mjs",
"build": "npm run build:lib && npm run build:scripts", "build": "npm run build:lib && npm run build:scripts",
"build:clean": "npm run clean && npm run build", "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 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: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: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');\"", "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", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
"start": "node dist/scripts/supervisor-entrypoint.js", "start": "node dist/scripts/supervisor-entrypoint.js",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration", "test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "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/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: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:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "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:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts", "test:integration:real": "vitest run real.e2e.test.ts",
@@ -1,55 +0,0 @@
// 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 <dir>]
// 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)) });
}
@@ -12,7 +12,6 @@ import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { daemonLogPath } from "../src/server/daemon-instance.js"; import { daemonLogPath } from "../src/server/daemon-instance.js";
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { reapTerminalWorkers } from "../src/terminal/terminal-worker-reaper.js";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
@@ -176,10 +175,6 @@ async function main(): Promise<void> {
onWorkerExit: () => onWorkerExit: () =>
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock, onSupervisorExit: releaseLock,
cleanupTerminals: async (log) => {
const result = await reapTerminalWorkers({ paseoHome, log });
log("Terminal worker cleanup result", { ...result });
},
}); });
requestSupervisorShutdown = supervisor.requestShutdown; requestSupervisorShutdown = supervisor.requestShutdown;
} }
@@ -1,180 +0,0 @@
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<number | null>((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",
]);
});
});
+17 -75
View File
@@ -21,7 +21,6 @@ type WorkerLifecycleMessage =
| { | {
type: "paseo:shutdown"; type: "paseo:shutdown";
reason?: string; reason?: string;
keepTerminals: boolean;
} }
| { | {
type: "paseo:ready"; type: "paseo:ready";
@@ -31,7 +30,6 @@ type WorkerLifecycleMessage =
| { | {
type: "paseo:restart"; type: "paseo:restart";
reason?: string; reason?: string;
keepTerminals: boolean;
}; };
interface SupervisorHeartbeatMessage { interface SupervisorHeartbeatMessage {
@@ -41,11 +39,8 @@ interface SupervisorHeartbeatMessage {
interface SupervisorGracefulShutdownMessage { interface SupervisorGracefulShutdownMessage {
type: "paseo:graceful-shutdown"; type: "paseo:graceful-shutdown";
reason: string; reason: string;
keepTerminals: boolean;
} }
export type SupervisorLifecycleLog = (message: string, fields?: Record<string, unknown>) => void;
interface SupervisorOptions { interface SupervisorOptions {
name: string; name: string;
startupMessage: string; startupMessage: string;
@@ -62,15 +57,11 @@ interface SupervisorOptions {
onWorkerExit?: () => Promise<void> | void; onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean; restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void; onSupervisorExit?: () => Promise<void> | 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<void>;
logFile?: SupervisorLogFileOptions; logFile?: SupervisorLogFileOptions;
} }
export interface SupervisorController { export interface SupervisorController {
requestShutdown(reason: string, options?: { keepTerminals?: boolean }): void; requestShutdown(reason: string): void;
} }
function describeExit(code: number | null, signal: NodeJS.Signals | null): string { function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
@@ -83,11 +74,10 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
} }
const type = (msg as { type?: unknown }).type; const type = (msg as { type?: unknown }).type;
if (type === "paseo:shutdown") { if (type === "paseo:shutdown") {
const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; const reason = (msg as { reason?: unknown }).reason;
return { return {
type: "paseo:shutdown", type: "paseo:shutdown",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
keepTerminals: keepTerminals === true,
}; };
} }
if (type === "paseo:ready") { if (type === "paseo:ready") {
@@ -101,11 +91,10 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return { type: "paseo:ready", listen, serverId }; return { type: "paseo:ready", listen, serverId };
} }
if (type === "paseo:restart") { if (type === "paseo:restart") {
const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; const reason = (msg as { reason?: unknown }).reason;
return { return {
type: "paseo:restart", type: "paseo:restart",
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
keepTerminals: keepTerminals === true,
}; };
} }
return null; return null;
@@ -196,9 +185,6 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let child: ChildProcess | null = null; let child: ChildProcess | null = null;
let restarting = false; let restarting = false;
let shuttingDown = 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 exiting = false;
let lifecycleFailed = false; let lifecycleFailed = false;
let publication = Promise.resolve(); let publication = Promise.resolve();
@@ -231,31 +217,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
const closeLogStream = (): Promise<void> => durableLog.close(); const closeLogStream = (): Promise<void> => durableLog.close();
const runTerminalCleanup = async (when: string): Promise<void> => { const exitSupervisor = (code: number): void => {
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) { if (exiting) {
return; return;
} }
exiting = true; exiting = true;
const cleanup = Promise.resolve(options.onSupervisorExit?.())
exitOptions.keepTerminals === true
? Promise.resolve()
: runTerminalCleanup("supervisor_exit");
cleanup
.then(() => options.onSupervisorExit?.())
.catch((error) => { .catch((error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`); log(`Supervisor exit cleanup failed: ${message}`);
@@ -390,20 +357,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (lifecycleMessage.type === "paseo:shutdown") { if (lifecycleMessage.type === "paseo:shutdown") {
const reason = lifecycleMessage.reason ?? "worker_requested_shutdown"; const reason = lifecycleMessage.reason ?? "worker_requested_shutdown";
writeLifecycleLog("Worker requested shutdown", { writeLifecycleLog("Worker requested shutdown", { reason });
reason, requestShutdown(reason);
keepTerminals: lifecycleMessage.keepTerminals,
});
requestShutdown(reason, { keepTerminals: lifecycleMessage.keepTerminals });
return; return;
} }
const reason = lifecycleMessage.reason ?? "worker_requested_restart"; const reason = lifecycleMessage.reason ?? "worker_requested_restart";
writeLifecycleLog("Worker requested restart", { writeLifecycleLog("Worker requested restart", { reason });
reason, requestRestart(reason);
keepTerminals: lifecycleMessage.keepTerminals,
});
requestRestart(reason, lifecycleMessage.keepTerminals);
}); });
child.on("error", (error) => { child.on("error", (error) => {
@@ -434,7 +395,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
if (shuttingDown) { if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
exitSupervisor(0, { keepTerminals: pendingKeepTerminals }); exitSupervisor(0);
return; return;
} }
@@ -443,25 +404,13 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) { if (restarting || crashed) {
const closeTerminals = restarting && !pendingKeepTerminals;
restarting = false; restarting = false;
pendingKeepTerminals = false;
log( log(
crashed && !closeTerminals crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...` ? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`, : `Worker exited (${exitDescriptor}). Restarting worker...`,
); );
if (!closeTerminals) { spawnWorker();
spawnWorker();
return;
}
// The replacement daemon must never adopt terminals the user asked to close.
void runTerminalCleanup("before_respawn").then(() => {
if (!shuttingDown) {
spawnWorker();
}
return;
});
return; return;
} }
@@ -480,11 +429,9 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
const message: SupervisorGracefulShutdownMessage = { const message: SupervisorGracefulShutdownMessage = {
type: "paseo:graceful-shutdown", type: "paseo:graceful-shutdown",
reason, reason,
keepTerminals: pendingKeepTerminals,
}; };
writeLifecycleLog("Supervisor requesting graceful worker shutdown", { writeLifecycleLog("Supervisor requesting graceful worker shutdown", {
reason, reason,
keepTerminals: pendingKeepTerminals,
supervisorPid: process.pid, supervisorPid: process.pid,
workerPid: currentChild.pid ?? null, workerPid: currentChild.pid ?? null,
}); });
@@ -508,33 +455,28 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
}); });
}; };
const requestRestart = (reason: string, keepTerminals: boolean) => { const requestRestart = (reason: string) => {
if (!child || restarting || shuttingDown) { if (!child || restarting || shuttingDown) {
return; return;
} }
restarting = true; restarting = true;
pendingKeepTerminals = keepTerminals; writeLifecycleLog("Restart requested", { reason });
writeLifecycleLog("Restart requested", { reason, keepTerminals });
log(`${reason}. Stopping worker for restart...`); log(`${reason}. Stopping worker for restart...`);
requestWorkerShutdown(reason); requestWorkerShutdown(reason);
scheduleForceKill(reason); scheduleForceKill(reason);
}; };
const requestShutdown = (reason: string, shutdownOptions: { keepTerminals?: boolean } = {}) => { const requestShutdown = (reason: string) => {
if (shuttingDown) { if (shuttingDown) {
return; return;
} }
shuttingDown = true; shuttingDown = true;
restarting = false; restarting = false;
pendingKeepTerminals = shutdownOptions.keepTerminals === true; writeLifecycleLog("Supervisor shutdown requested", { reason });
writeLifecycleLog("Supervisor shutdown requested", {
reason,
keepTerminals: pendingKeepTerminals,
});
log(`${reason}. Stopping worker...`); log(`${reason}. Stopping worker...`);
if (!child) { if (!child) {
void publication.then(() => { void publication.then(() => {
exitSupervisor(lifecycleFailed ? 1 : 0, { keepTerminals: pendingKeepTerminals }); exitSupervisor(lifecycleFailed ? 1 : 0);
return; return;
}); });
return; return;
@@ -1,10 +1,17 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { delimiter, resolve } from "node:path"; import { delimiter, resolve } from "node:path";
import type { ProcessEnvRecord } from "../server/paseo-env.js"; import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js"; import { execCommand } from "../utils/spawn.js";
import which from "which";
import { windowsExecutableResolution } from "./windows.js"; import { windowsExecutableResolution } from "./windows.js";
type Which = (
command: string,
options: { all: true; path?: string; pathExt?: string },
) => Promise<string[]>;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
const PROBE_TIMEOUT_MS = 2000; const PROBE_TIMEOUT_MS = 2000;
function hasPathSeparator(value: string): boolean { function hasPathSeparator(value: string): boolean {
-10
View File
@@ -1,10 +0,0 @@
// `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<string[]>;
export default which;
}
@@ -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(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]);
expect(await storage.get(agentId)).not.toBeNull(); expect(await storage.get(agentId)).not.toBeNull();
// Workspace ownership preserves ordinary agent lifecycle hooks. // Workspace ownership preserves ordinary agent lifecycle hooks.
expect(beforeHookCalls).toContain("agent.create.before"); expect(beforeHookCalls).toContain("agent.create");
}); });
test("global subscribers receive internal agent events only when they opt in", async () => { test("global subscribers receive internal agent events only when they opt in", async () => {
@@ -12072,15 +12072,15 @@ test.each([
const session = client.sessions[0]!; const session = client.sessions[0]!;
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" }); session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
await expect await expect
.poll(() => events.filter(({ name }) => name === "agent.turn.before").length) .poll(() => events.filter(({ name }) => name === "agent.turn_started").length)
.toBe(1); .toBe(1);
expect(timelineStore.itemReads).toBe(readsBeforeDispatch); expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
session.pushEvent(terminalEvent); session.pushEvent(terminalEvent);
await expect await expect
.poll(() => events.filter(({ name }) => name === "agent.turn.after")) .poll(() => events.filter(({ name }) => name === "agent.turn_ended"))
.toEqual([ .toEqual([
{ {
name: "agent.turn.after", name: "agent.turn_ended",
event: { event: {
agent: { agent: {
id: agent.id, id: agent.id,
@@ -1358,7 +1358,7 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) { if (this.pluginLifecycle && !config.internal) {
const request = await this.pluginLifecycle.before("agent.create.before", { const request = await this.pluginLifecycle.before("agent.create", {
config, config,
env: options.env, env: options.env,
}); });
@@ -1402,7 +1402,7 @@ export class AgentManager {
historyPrimed: true, historyPrimed: true,
}); });
if (!agent.internal) { if (!agent.internal) {
this.pluginLifecycle?.emit("agent.create.after", { this.pluginLifecycle?.emit("agent.created", {
agent: describeHookAgent({ ...agent, title: agent.config.title }), agent: describeHookAgent({ ...agent, title: agent.config.title }),
}); });
} }
@@ -2114,7 +2114,7 @@ export class AgentManager {
const archivedRecord = buildArchivedAgentRecord(record, options); const archivedRecord = buildArchivedAgentRecord(record, options);
await this.requireRegistry().upsert(archivedRecord); await this.requireRegistry().upsert(archivedRecord);
if (!record.archivedAt && !record.internal) { if (!record.archivedAt && !record.internal) {
this.pluginLifecycle?.emit("agent.archive.after", { this.pluginLifecycle?.emit("agent.archived", {
agent: describeHookAgent(archivedRecord), agent: describeHookAgent(archivedRecord),
archivedAt: archivedRecord.archivedAt, archivedAt: archivedRecord.archivedAt,
}); });
@@ -4051,7 +4051,7 @@ export class AgentManager {
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
this.emitState(agent, options); this.emitState(agent, options);
if (!agent.internal) { if (!agent.internal) {
this.pluginLifecycle?.emit("agent.close.after", { this.pluginLifecycle?.emit("agent.closed", {
agent: describeHookAgent({ ...agent, title: agent.config.title }), agent: describeHookAgent({ ...agent, title: agent.config.title }),
}); });
} }
@@ -5552,7 +5552,7 @@ export class AgentManager {
tools: [], tools: [],
mcpServers: {}, mcpServers: {},
}; };
const transformed = await this.pluginLifecycle.before("agent.open.before", request); const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env; env = transformed.env;
if (request.purpose === "interactive") { if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
@@ -76,7 +76,7 @@ import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.j
import type { ForgeService } from "../../services/forge-service.js"; import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js"; import { areEquivalentPaths } from "../../utils/path.js";
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js"; import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
import { startWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
@@ -1384,24 +1384,13 @@ describe("terminal MCP tools", () => {
); );
it.each([ it.each([
["in-process", async () => ({ manager: createTerminalManager(), home: null })], ["in-process", createTerminalManager],
[ ["worker", createWorkerTerminalManager],
"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)( ] as const)(
"delivers MCP key tokens through %s after terminal input modes change", "delivers MCP key tokens through %s after terminal input modes change",
async (_name, createManager) => { async (_name, createManager) => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const { manager: terminalManager, home } = await createManager(); const terminalManager = createManager();
const terminal = await terminalManager.createTerminal({ const terminal = await terminalManager.createTerminal({
cwd: process.cwd(), cwd: process.cwd(),
workspaceId: "ws-terminal-keys", workspaceId: "ws-terminal-keys",
@@ -1431,15 +1420,15 @@ describe("terminal MCP tools", () => {
logger, 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 = ""; let output = "";
const unsubscribe = terminal.subscribe((message) => { const unsubscribe = terminal.subscribe((message) => {
if (message.type === "output") { if (message.type === "output") {
output += message.data; 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 { try {
await vi.waitFor(async () => { await vi.waitFor(async () => {
const capture = await terminalManager.captureTerminal(terminal.id); const capture = await terminalManager.captureTerminal(terminal.id);
@@ -1476,7 +1465,6 @@ describe("terminal MCP tools", () => {
await server.close(); await server.close();
await terminalManager.killTerminalAndWait(terminal.id); await terminalManager.killTerminalAndWait(terminal.id);
terminalManager.killAll(); terminalManager.killAll();
if (home) await rm(home, { recursive: true, force: true });
} }
}, },
); );
@@ -455,15 +455,9 @@ test.each([
let original: Awaited<ReturnType<typeof client.createSession>> | undefined; let original: Awaited<ReturnType<typeof client.createSession>> | undefined;
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | undefined; let resumed: Awaited<ReturnType<typeof client.resumeSession>> | undefined;
try { 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( await execCommand(
"npm", "npm",
["install", "--prefix", root, "--no-audit", "--no-fund", installPackage], ["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage],
{ {
cwd: root, cwd: root,
timeout: 180_000, timeout: 180_000,
+6 -30
View File
@@ -180,7 +180,7 @@ import {
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalManager } from "../terminal/terminal-manager.js";
import { startWorkerTerminalManager } from "../terminal/worker-terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
@@ -396,14 +396,12 @@ export type DaemonLifecycleIntent =
clientId: string; clientId: string;
requestId: string; requestId: string;
reason: string; reason: string;
keepTerminals: boolean;
} }
| { | {
type: "restart"; type: "restart";
clientId: string; clientId: string;
requestId: string; requestId: string;
reason: string; reason: string;
keepTerminals: boolean;
}; };
export interface PaseoDaemonConfig { export interface PaseoDaemonConfig {
@@ -495,8 +493,7 @@ export interface PaseoDaemon {
scriptRuntimeStore: WorkspaceScriptRuntimeStore; scriptRuntimeStore: WorkspaceScriptRuntimeStore;
browserToolsBroker: BrowserToolsBroker; browserToolsBroker: BrowserToolsBroker;
start(): Promise<void>; start(): Promise<void>;
/** Stops the daemon. Terminals keep running for the next daemon only with keepTerminals. */ stop(): Promise<void>;
stop(options?: { keepTerminals?: boolean }): Promise<void>;
getListenTarget(): ListenTarget | null; getListenTarget(): ListenTarget | null;
getServerId(): string; getServerId(): string;
} }
@@ -712,10 +709,7 @@ export async function createPaseoDaemon(
}); });
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const terminalManager = await startWorkerTerminalManager({ const terminalManager = createConfiguredTerminalManager({
paseoHome: config.paseoHome,
paseoVersion: daemonVersion,
logger,
getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget),
}); });
applyTerminalAgentHookSetting({ store: daemonConfigStore, logger }); applyTerminalAgentHookSetting({ store: daemonConfigStore, logger });
@@ -963,7 +957,7 @@ export async function createPaseoDaemon(
if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd);
} }
if (mutation.kind === "archive" && mutation.workspace) { if (mutation.kind === "archive" && mutation.workspace) {
pluginRuntime.emit("workspace.archive.after", { pluginRuntime.emit("workspace.archived", {
workspace: describeHookWorkspace(mutation.workspace), workspace: describeHookWorkspace(mutation.workspace),
}); });
} }
@@ -1212,7 +1206,6 @@ export async function createPaseoDaemon(
worktreesRoot: config.worktreesRoot, worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => { createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, { return createRegisteredPaseoWorktree(workflowInput, {
lifecycle: pluginRuntime,
github, github,
...(workflowOptions?.resolveDefaultBranch ...(workflowOptions?.resolveDefaultBranch
? { ? {
@@ -1942,9 +1935,7 @@ export async function createPaseoDaemon(
} }
}; };
const stop = async (options?: { keepTerminals?: boolean }) => { const stop = async () => {
const keepTerminals = options?.keepTerminals === true;
let terminalShutdownError: unknown = null;
localCredential = null; localCredential = null;
await deleteLocalCredential(config.paseoHome); await deleteLocalCredential(config.paseoHome);
// Stop tracking plugin provider registrations before anything tears plugins // Stop tracking plugin provider registrations before anything tears plugins
@@ -1974,19 +1965,7 @@ export async function createPaseoDaemon(
await agentStorage.flush().catch(() => undefined); await agentStorage.flush().catch(() => undefined);
await agentProviderRuntime.shutdown(); await agentProviderRuntime.shutdown();
await pluginRuntime.stopAllPlugins(); await pluginRuntime.stopAllPlugins();
try { terminalManager.killAll();
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 speechService.stop();
await scheduleService.stop().catch(() => undefined); await scheduleService.stop().catch(() => undefined);
await relayRuntime?.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined);
@@ -2009,9 +1988,6 @@ export async function createPaseoDaemon(
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
unlinkSync(listenTarget.path); unlinkSync(listenTarget.path);
} }
if (terminalShutdownError) {
throw terminalShutdownError;
}
}; };
return { return {
@@ -1,688 +0,0 @@
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<number>();
const extraDirs: string[] = [];
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor<T>(
probe: () => Promise<T | null | undefined | false> | T | null | undefined | false,
message: string,
timeoutMs = READY_TIMEOUT_MS,
): Promise<T> {
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<number> {
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
await new Promise<void>((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<Harness> {
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<DaemonClient> {
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<void> {
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<number> {
return (await client.getDaemonStatus()).pid;
}
async function waitForReplacement(harness: Harness, previousPid: number): Promise<DaemonClient> {
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<string> {
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<MarkedTerminal> {
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<string> {
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<TerminalWorkerRecord[]> {
return (await listTerminalWorkerRecords(home)).flatMap((file) =>
"record" in file ? [file.record] : [],
);
}
async function currentWorkerRecord(home: string): Promise<TerminalWorkerRecord> {
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<string> {
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<void>;
};
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);
});
});
@@ -11,7 +11,6 @@ import {
} from "./pid-lock.js"; } from "./pid-lock.js";
import { daemonLaunchEnvironment } from "./config-environment.js"; import { daemonLaunchEnvironment } from "./config-environment.js";
import { readPersistedConfig } from "./persisted-config.js"; import { readPersistedConfig } from "./persisted-config.js";
import { reapTerminalWorkers } from "../terminal/terminal-worker-reaper.js";
import treeKill from "tree-kill"; import treeKill from "tree-kill";
const killTree = (pid: number, signal: string): Promise<void> => const killTree = (pid: number, signal: string): Promise<void> =>
new Promise((resolve, reject) => new Promise((resolve, reject) =>
@@ -30,10 +29,6 @@ export { resolvePaseoHome } from "./paseo-home.js";
export { ensurePrivateDirectory } from "./private-files.js"; export { ensurePrivateDirectory } from "./private-files.js";
export { daemonLaunchEnvironment } from "./config-environment.js"; export { daemonLaunchEnvironment } from "./config-environment.js";
export { readLocalCredentialForTarget } from "./local-credential.js"; export { readLocalCredentialForTarget } from "./local-credential.js";
export {
TerminalCleanupNotConfirmedError,
retryUnconfirmedTerminalCleanup,
} from "../terminal/terminal-worker-reaper.js";
export { export {
isSamePidLock as isSameDaemonInstance, isSamePidLock as isSameDaemonInstance,
type PidLockInfo as DaemonInstance, type PidLockInfo as DaemonInstance,
@@ -102,23 +97,11 @@ async function requestInstanceStop(
instance: PidLockInfo, instance: PidLockInfo,
options: { options: {
force?: boolean; force?: boolean;
keepTerminals?: boolean;
requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise<void>; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise<void>;
}, },
) { ) {
let forced = false; let forced = false;
let usedLifecycleRpc = 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 (process.platform === "win32") {
if (instance.listen && options.requestShutdown) { if (instance.listen && options.requestShutdown) {
try { try {
@@ -159,8 +142,6 @@ export async function stopDaemonInstance(
options: { options: {
instance?: PidLockInfo; instance?: PidLockInfo;
force?: boolean; force?: boolean;
// Leave the terminal worker running so the next daemon adopts it. Not valid with force.
keepTerminals?: boolean;
timeoutMs?: number; timeoutMs?: number;
killTimeoutMs?: number; killTimeoutMs?: number;
requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise<void>; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise<void>;
@@ -172,7 +153,6 @@ export async function stopDaemonInstance(
usedLifecycleRpc: boolean; usedLifecycleRpc: boolean;
}> { }> {
const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options;
assertStopOptions(options);
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const instance = await getPidLockInfo(home); const instance = await getPidLockInfo(home);
if (options.instance && instance && !isSamePidLock(instance, options.instance)) { if (options.instance && instance && !isSamePidLock(instance, options.instance)) {
@@ -184,9 +164,6 @@ export async function stopDaemonInstance(
if (!instance || !isPidLockOwnerRunning(instance)) { if (!instance || !isPidLockOwnerRunning(instance)) {
if (instance) if (instance)
await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); 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 { return {
action: "not_running", action: "not_running",
pid: instance?.pid ?? null, pid: instance?.pid ?? null,
@@ -223,29 +200,9 @@ export async function stopDaemonInstance(
`Timed out waiting for supervisor PID ${instance.pid} in ${home} to exit${options.force ? "" : "; use --force to permit forced cleanup"}.`, `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 }); 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 }; 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<void> {
if (options.keepTerminals) return;
await reapTerminalWorkers({ paseoHome: home, gracefulMs: options.forced ? 0 : undefined });
}
export async function startDaemonInstance(input: { export async function startDaemonInstance(input: {
home: string; home: string;
command: string; command: string;
+19 -42
View File
@@ -13,7 +13,6 @@ type SupervisorLifecycleMessage =
| { | {
type: "paseo:shutdown"; type: "paseo:shutdown";
reason: string; reason: string;
keepTerminals: boolean;
} }
| { | {
type: "paseo:ready"; type: "paseo:ready";
@@ -23,7 +22,6 @@ type SupervisorLifecycleMessage =
| { | {
type: "paseo:restart"; type: "paseo:restart";
reason?: string; reason?: string;
keepTerminals: boolean;
}; };
interface BootstrapResult { interface BootstrapResult {
@@ -153,15 +151,12 @@ async function main() {
options?: { options?: {
reason?: string; reason?: string;
successExitCode?: number; successExitCode?: number;
keepTerminals?: boolean;
}, },
) => { ) => {
const reason = options?.reason ?? `worker_received_${signal}`; 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) { if (!shutdownPromise) {
logger.info( logger.info(
{ signal, reason, keepTerminals, ...getProcessDiagnostics() }, { signal, reason, ...getProcessDiagnostics() },
`${signal} received, shutting down gracefully...`, `${signal} received, shutting down gracefully...`,
); );
@@ -180,7 +175,7 @@ async function main() {
clearTimeout(forceExit); clearTimeout(forceExit);
return 1; return 1;
} }
await daemon.stop({ keepTerminals }); await daemon.stop();
clearTimeout(forceExit); clearTimeout(forceExit);
logger.info("Server closed"); logger.info("Server closed");
return options?.successExitCode ?? 0; return options?.successExitCode ?? 0;
@@ -214,53 +209,36 @@ async function main() {
}; };
const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => { const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => {
const fields = {
clientId: intent.clientId,
requestId: intent.requestId,
reason: intent.reason,
keepTerminals: intent.keepTerminals,
};
if (intent.type === "shutdown") { if (intent.type === "shutdown") {
logger.warn(fields, "Shutdown requested via websocket"); logger.warn(
const sent = sendSupervisorLifecycleMessage({ { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason },
type: "paseo:shutdown", "Shutdown requested via websocket",
reason: intent.reason, );
keepTerminals: intent.keepTerminals, if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown", reason: intent.reason })) {
});
recordLifecycleIntentDispatched(intent.type, fields);
if (sent) {
return; return;
} }
beginShutdown("shutdown lifecycle intent", { beginShutdown("shutdown lifecycle intent", { reason: intent.reason });
reason: intent.reason,
keepTerminals: intent.keepTerminals,
});
return; return;
} }
logger.warn(fields, "Restart requested via websocket"); logger.warn(
const sent = sendSupervisorLifecycleMessage({ { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason },
type: "paseo:restart", "Restart requested via websocket",
...(intent.reason ? { reason: intent.reason } : {}), );
keepTerminals: intent.keepTerminals, if (
}); sendSupervisorLifecycleMessage({
recordLifecycleIntentDispatched(intent.type, fields); type: "paseo:restart",
if (sent) { ...(intent.reason ? { reason: intent.reason } : {}),
})
) {
return; return;
} }
beginShutdown("restart lifecycle intent", { beginShutdown("restart lifecycle intent", {
reason: intent.reason, reason: intent.reason,
successExitCode: 0, 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<string, unknown>) => {
writeWorkerLifecycleLog(paseoHome, "Lifecycle intent dispatched", { type, ...fields });
};
const installSupervisorLivenessGuard = () => { const installSupervisorLivenessGuard = () => {
if (typeof process.send !== "function") { if (typeof process.send !== "function") {
return; return;
@@ -300,10 +278,9 @@ async function main() {
return; return;
} }
if (type === "paseo:graceful-shutdown") { if (type === "paseo:graceful-shutdown") {
const { reason, keepTerminals } = message as { reason?: unknown; keepTerminals?: unknown }; const reason = (message as { reason?: unknown }).reason;
beginShutdown("Supervisor shutdown request", { beginShutdown("Supervisor shutdown request", {
reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown", reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown",
keepTerminals: keepTerminals === true,
}); });
} }
}); });
@@ -12,7 +12,6 @@ import {
type ManagedProcessInspection, type ManagedProcessInspection,
type ManagedProcessSnapshot, type ManagedProcessSnapshot,
type ManagedProcessTable, type ManagedProcessTable,
windowsPowerShellPath,
} from "./managed-processes.js"; } from "./managed-processes.js";
import { spawnProcess } from "../../utils/spawn.js"; import { spawnProcess } from "../../utils/spawn.js";
import { import {
@@ -334,7 +333,7 @@ describe("system managed process table", () => {
}); });
expect(commandRunner.commands).toEqual([ expect(commandRunner.commands).toEqual([
{ {
command: windowsPowerShellPath(), command: "powershell.exe",
args: [ args: [
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
@@ -346,17 +345,6 @@ 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 { class FakeProcessTable implements ManagedProcessTable {
private readonly snapshots: Map<number, ManagedProcessSnapshot>; private readonly snapshots: Map<number, ManagedProcessSnapshot>;
private readonly errorPids: Set<number>; private readonly errorPids: Set<number>;
@@ -7,20 +7,6 @@ import { writeJsonFileAtomic } from "../atomic-file.js";
import { execCommand } from "../../utils/spawn.js"; import { execCommand } from "../../utils/spawn.js";
import type { ProcessTerminator, TreeKillTarget } from "../../utils/tree-kill.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_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50; const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50;
@@ -197,7 +183,7 @@ class SystemManagedProcessTable implements ManagedProcessTable {
`$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`, `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`,
"if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }", "if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }",
].join(" "); ].join(" ");
const { stdout } = await this.commandRunner.exec(windowsPowerShellPath(), [ const { stdout } = await this.commandRunner.exec("powershell.exe", [
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
"-Command", "-Command",
@@ -1,4 +1,3 @@
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
@@ -60,7 +59,6 @@ export interface AttemptFirstAgentBranchAutoNameResult {
} }
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
lifecycle?: Pick<PluginLifecycle, "wait" | "before">;
workspaceGitService: WorkspaceGitService; workspaceGitService: WorkspaceGitService;
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">; workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
} }
@@ -79,15 +77,6 @@ async function createPaseoWorktreeWithPriority(
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
const createdWorktree = await createWorktreeCore(input, deps); const createdWorktree = await createWorktreeCore(input, deps);
try { 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 }); await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
@@ -166,7 +155,7 @@ async function isDirectory(targetPath: string): Promise<boolean> {
async function planWorkspaceCwdForWorktree( async function planWorkspaceCwdForWorktree(
inputCwd: string, inputCwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">, workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<{ inputCwd: string; relativeWorkspaceCwd: string; sourceCheckout: string }> { ): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> {
const normalizedInputCwd = resolve(inputCwd); const normalizedInputCwd = resolve(inputCwd);
const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd);
const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd;
@@ -174,7 +163,7 @@ async function planWorkspaceCwdForWorktree(
if (relativeWorkspaceCwd === null) { if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`);
} }
return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd, sourceCheckout: sourceWorktreePath }; return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd };
} }
export async function attemptFirstAgentBranchAutoName(options: { export async function attemptFirstAgentBranchAutoName(options: {
+8 -18
View File
@@ -30,16 +30,13 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && "code" in err; 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 { export class PidLockError extends Error {
readonly existingLock?: PidLockInfo; constructor(
message: string,
constructor(message: string, existingLock?: PidLockInfo) { public readonly existingLock?: PidLockInfo,
) {
super(message); super(message);
this.name = "PidLockError"; this.name = "PidLockError";
this.existingLock = existingLock;
} }
} }
@@ -72,7 +69,7 @@ let cachedBootId: string | null | undefined;
// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while // 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 // its host sleeps keeps its uptime, so after it resumes the instant lands after locks its
// running supervisor wrote. // running supervisor wrote.
export function currentBootId(): string | null { function currentBootId(): string | null {
if (cachedBootId === undefined) { if (cachedBootId === undefined) {
try { try {
cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null;
@@ -83,17 +80,10 @@ export function currentBootId(): string | null {
return cachedBootId; 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(); const thisBoot = currentBootId();
if (stamp.bootId && thisBoot) return stamp.bootId === thisBoot; if (lock.bootId && thisBoot) return lock.bootId === thisBoot;
return !precedesThisBoot(stamp.startedAt); return !precedesThisBoot(lock.startedAt);
} }
/** /**
@@ -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 directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
const launches: AgentSessionConfig[] = []; const launches: AgentSessionConfig[] = [];
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.1", daemonVersion: "0.13.2",
agentClients: { agentClients: {
codex: createTestAgentClient("codex", { codex: createTestAgentClient("codex", {
supportsMcpServers: true, supportsMcpServers: true,
@@ -25,7 +25,7 @@ test("the configuration example saves Codex options and injects launch-only MCP
}); });
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`, url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.1", appVersion: "0.13.2",
}); });
try { try {
await client.connect(); await client.connect();
@@ -80,7 +80,7 @@ const tool = defineAgentTool({
}, },
}); });
export default function contribute(server) { export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
if (request.labels.x !== "y") return; 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] }; 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 directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-"));
const recorder = recordingProvider(native, providerOptions); const recorder = recordingProvider(native, providerOptions);
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.1", daemonVersion: "0.13.2",
agentClients: { codex: recorder.provider }, agentClients: { codex: recorder.provider },
mcpEnabled: true, mcpEnabled: true,
providerOverrides: { codex: { paseoTools: policy } }, providerOverrides: { codex: { paseoTools: policy } },
}); });
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`, url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.1", appVersion: "0.13.2",
}); });
const mcps: Client[] = []; const mcps: Client[] = [];
async function install(id: string, code: string) { async function install(id: string, code: string) {
@@ -112,7 +112,7 @@ async function setup(
await mkdir(pluginDirectory); await mkdir(pluginDirectory);
await writeFile( await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"), path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: ">=0.14.0-beta.1" } }), JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }),
); );
await writeFile(path.join(pluginDirectory, "index.server.ts"), code); await writeFile(path.join(pluginDirectory, "index.server.ts"), code);
await client.installDirectoryPlugin(pluginDirectory); 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( const source = pluginSource.replace(
" return () => {};", " return () => {};",
` `
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions");
return { ...request, tools: [] }; return { ...request, tools: [] };
}); });
@@ -501,12 +501,12 @@ test("built-in plugins attach and execute tools while configured plugins are dis
await mkdir(directory); await mkdir(directory);
await writeFile( await writeFile(
path.join(directory, "paseo-plugin.json"), path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.14.0-beta.1" } }), JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }),
); );
await writeFile(path.join(directory, "index.server.ts"), pluginSource); await writeFile(path.join(directory, "index.server.ts"), pluginSource);
const recorder = recordingProvider(true); const recorder = recordingProvider(true);
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.1", daemonVersion: "0.13.2",
agentClients: { codex: recorder.provider }, agentClients: { codex: recorder.provider },
pluginsEnabled: false, pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), 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 companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const;
const mcpSource = ` const mcpSource = `
export default function contribute(server) { export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return; if (request.provider !== "codex") return;
return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} },
env: { ...request.env, OPEN_REASON: request.reason } }; 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", "z-extend",
` `
export default function contribute(server) { export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked");
if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); 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" } } } }; return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } };
}); });
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); 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" } } }; return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } };
}); });
server.addHook("agent.open.before", async () => undefined); server.before("agent.session_open", () => undefined);
return () => {}; return () => {};
}`, }`,
); );
@@ -1016,7 +1016,7 @@ export default function contribute(server) {
await h.install( await h.install(
"zz-remove", "zz-remove",
`export default function contribute(server) { `export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => ({ ...request, mcpServers: {} })); server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} }));
return () => {}; 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 }'), mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'),
); );
try { try {
await expect(h.create()).rejects.toThrow(/Plugin tools-test.*agent.open.before/s); await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s);
expect(h.launches).toHaveLength(0); expect(h.launches).toHaveLength(0);
} finally { } finally {
await h.close(); await h.close();
@@ -14,7 +14,6 @@ export const builtinPlugins = [
"minimax-usage-source", "minimax-usage-source",
"muse-provider", "muse-provider",
"opencode-go-usage-source", "opencode-go-usage-source",
"worktree-include",
"zai-usage-source", "zai-usage-source",
] as const; ] as const;
@@ -15,7 +15,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import pino from "pino"; import pino from "pino";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js"; import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js"; import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js"; import { BuiltinPluginLoader } from "./builtin/index.js";
@@ -730,7 +730,7 @@ export default function contribute(server: PluginServerContext) {
await service.stopAllPlugins(); await service.stopAllPlugins();
} }
}, },
30_000, 60_000,
); );
it("keeps the running commit when a Git update build command fails", async () => { it("keeps the running commit when a Git update build command fails", async () => {
@@ -1321,11 +1321,7 @@ export default function contribute(plugin: unknown) {
}); });
describe("npm plugin installation", () => { describe("npm plugin installation", () => {
let registry: Awaited<ReturnType<typeof startNpmRegistry>>; it("preserves dependencies and metadata, and reloads without npm", async () => {
// 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 packages = npmPluginPackages();
const fixture = packages[1]; const fixture = packages[1];
packages.push({ ...fixture, version: "2.0.0", tags: ["future"] }); packages.push({ ...fixture, version: "2.0.0", tags: ["future"] });
@@ -1360,7 +1356,7 @@ describe("npm plugin installation", () => {
for (const [name, overrides] of failures) { for (const [name, overrides] of failures) {
failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } }); failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } });
} }
registry = await startNpmRegistry([ const registry = await startNpmRegistry([
...packages, ...packages,
{ {
name: "paseo-prepared-plugin", name: "paseo-prepared-plugin",
@@ -1390,9 +1386,6 @@ export default function contribute() {
}, },
...failedPackages, ...failedPackages,
]); ]);
}, 120_000);
it("preserves dependencies and metadata, and reloads without npm", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-"));
roots.push(home); roots.push(home);
const previousConfig = process.env.npm_config_userconfig; const previousConfig = process.env.npm_config_userconfig;
@@ -45,7 +45,6 @@ interface PluginRuntimePort {
emit?: PluginLifecycle["emit"]; emit?: PluginLifecycle["emit"];
drainEvents?: PluginRuntime["drainEvents"]; drainEvents?: PluginRuntime["drainEvents"];
before?: PluginLifecycle["before"]; before?: PluginLifecycle["before"];
wait?: PluginLifecycle["wait"];
catalog: PluginRuntime["catalog"]; catalog: PluginRuntime["catalog"];
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>; invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[]; getLogs(pluginId: string): PluginLogEntry[];
@@ -169,10 +168,6 @@ export class PluginService {
return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); 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<void> { async drainEvents(): Promise<void> {
await this.runtime.drainEvents?.(); await this.runtime.drainEvents?.();
} }
@@ -8,11 +8,8 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { test("closing and archiving agents publish their distinct lifecycle hooks once", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.1",
});
try { try {
await client.connect(); await client.connect();
await client.fetchAgents({ subscribe: {} }); await client.fetchAgents({ subscribe: {} });
@@ -42,7 +39,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once",
.poll(async () => { .poll(async () => {
const logs = await client.getPluginLogs("lifecycle-logger"); const logs = await client.getPluginLogs("lifecycle-logger");
return logs return logs
.filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"'))
.map((entry) => JSON.parse(entry.message).data.agent.id); .map((entry) => JSON.parse(entry.message).data.agent.id);
}) })
.toEqual([closed.id]); .toEqual([closed.id]);
@@ -52,7 +49,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once",
const logs = await client.getPluginLogs("lifecycle-logger"); const logs = await client.getPluginLogs("lifecycle-logger");
return logs return logs
.filter((entry) => { .filter((entry) => {
return entry.message.startsWith('{"hook":"agent.archive.after"'); return entry.message.startsWith('{"hook":"agent.archived"');
}) })
.map((entry) => { .map((entry) => {
return JSON.parse(entry.message).data.agent.id; return JSON.parse(entry.message).data.agent.id;
@@ -64,7 +61,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once",
.poll(async () => { .poll(async () => {
const logs = await client.getPluginLogs("lifecycle-logger"); const logs = await client.getPluginLogs("lifecycle-logger");
return logs return logs
.filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"'))
.map((entry) => JSON.parse(entry.message).data.agent.id) .map((entry) => JSON.parse(entry.message).data.agent.id)
.sort(); .sort();
}) })
@@ -78,23 +75,20 @@ test("closing and archiving agents publish their distinct lifecycle hooks once",
test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.1",
});
const completedFile = path.join(directory, "closed.txt"); const completedFile = path.join(directory, "closed.txt");
try { try {
await writeFile( await writeFile(
path.join(directory, "paseo-plugin.json"), path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.14.0-beta.1" } }), JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }),
); );
await writeFile( await writeFile(
path.join(directory, "index.server.ts"), path.join(directory, "index.server.ts"),
`import { writeFile } from "node:fs/promises"; `import { writeFile } from "node:fs/promises";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
export default function contribute(server) { export default function contribute(server) {
server.addHook("agent.close.after", async (event, context) => { server.on("agent.closed", async (event, context) => {
await setTimeout(200, undefined, { signal: context.signal }); await setTimeout(200, undefined, { signal: context.signal });
await writeFile(${JSON.stringify(completedFile)}, event.agent.id); await writeFile(${JSON.stringify(completedFile)}, event.agent.id);
}); });
@@ -1,5 +1,4 @@
import { execFileSync } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -7,7 +6,6 @@ import { expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { resolveDaemonVersion } from "../daemon-version.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 () => { 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-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
@@ -76,76 +74,6 @@ export default function contribute(server) {
} }
}, 60_000); }, 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 () => { 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 directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
@@ -159,24 +87,24 @@ test("plugins observe turns, answer permissions, and observe archive without blo
path.join(directory, "index.server.ts"), path.join(directory, "index.server.ts"),
` `
export default function contribute(server) { export default function contribute(server) {
server.addHook("agent.turn.before", async (event) => { server.on("agent.turn_started", (event) => {
console.log(JSON.stringify({ hook: "agent.turn.before", event })); console.log(JSON.stringify({ hook: "agent.turn_started", event }));
}); });
server.addHook("agent.turn.after", async (event) => { server.on("agent.turn_ended", (event) => {
console.log(JSON.stringify({ hook: "agent.turn.after", event })); console.log(JSON.stringify({ hook: "agent.turn_ended", event }));
}); });
server.addHook("agent.permission.before", async (event, context) => { server.on("agent.permission_requested", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission.before", event })); console.log(JSON.stringify({ hook: "agent.permission_requested", event }));
await context.paseo.agents.ref(event.agent.id).respondToPermission({ await context.paseo.agents.ref(event.agent.id).respondToPermission({
requestId: event.request.id, requestId: event.request.id,
response: { behavior: "deny", message: "Declined by plugin" }, response: { behavior: "deny", message: "Declined by plugin" },
}); });
}); });
server.addHook("agent.permission.after", async (event) => { server.on("agent.permission_resolved", (event) => {
console.log(JSON.stringify({ hook: "agent.permission.after", event })); console.log(JSON.stringify({ hook: "agent.permission_resolved", event }));
}); });
server.addHook("agent.archive.after", async (event) => { server.on("agent.archived", (event) => {
console.log(JSON.stringify({ hook: "agent.archive.after", event })); console.log(JSON.stringify({ hook: "agent.archived", event }));
}); });
return () => {}; return () => {};
} }
@@ -211,16 +139,16 @@ export default function contribute(server) {
{ timeout: 10_000 }, { timeout: 10_000 },
) )
.toMatchObject([ .toMatchObject([
{ hook: "agent.permission.after", event: { resolution: { behavior: "deny" } } },
{ {
hook: "agent.permission.before", hook: "agent.permission_requested",
event: { request: { input: { command: "rm -f permission.txt" } } }, event: { request: { input: { command: "rm -f permission.txt" } } },
}, },
{ hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } },
{ {
hook: "agent.turn.after", hook: "agent.turn_ended",
event: { outcome: { kind: "completed" }, timeline: [] }, event: { outcome: { kind: "completed" }, timeline: [] },
}, },
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } }, { hook: "agent.turn_started", event: { agent: { id: agent.id } } },
]); ]);
const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 }); const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 });
expect(page.entries.length).toBeGreaterThan(0); expect(page.entries.length).toBeGreaterThan(0);
@@ -229,7 +157,7 @@ export default function contribute(server) {
.poll(async () => { .poll(async () => {
const logs = await client.getPluginLogs("turn-hooks"); const logs = await client.getPluginLogs("turn-hooks");
return logs.filter((entry) => { return logs.filter((entry) => {
return entry.message.startsWith('{"hook":"agent.archive.after"'); return entry.message.startsWith('{"hook":"agent.archived"');
}).length; }).length;
}) })
.toBe(1); .toBe(1);
@@ -242,41 +170,44 @@ export default function contribute(server) {
test("agent creation hooks change the provider and environment before the session opens", async () => { 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 directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
const pluginDirectory = await createProviderPlugin(directory, { const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
acknowledgeClose: true, const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
hooks: ` try {
server.addHook("agent.create.before", async ({ request }) => { await writeFile(
return { ...request, config: { ...request.config, provider: "${PROVIDER_ID}", model: "shutdown-model" }, env: { ...request.env, HOOK_CREATE: "created" } }; 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" } };
}); });
server.addHook("agent.open.before", async ({ request }) => { server.before("agent.session_open", ({ request }) => {
console.log(JSON.stringify({ hook: "agent.open.before", request })); console.log(JSON.stringify({ hook: "agent.session_open", request }));
return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } };
}); });
server.addHook("agent.create.after", async (event) => { server.on("agent.created", (event) => {
console.log(JSON.stringify({ hook: "agent.create.after", event })); console.log(JSON.stringify({ hook: "agent.created", event }));
});`,
reportOpen: true,
}); });
const daemon = await createTestPaseoDaemon(); return () => {};
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); }
try { `,
);
await client.connect(); await client.connect();
await client.fetchAgents({ subscribe: {} }); await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true }); await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory); await client.installDirectoryPlugin(directory);
const agent = await client.createAgent({ const agent = await client.createAgent({
provider: "claude", provider: "claude",
cwd: directory, cwd: directory,
title: "Hook test", title: "Hook test",
}); });
expect(agent.provider).toBe(PROVIDER_ID); expect(agent.provider).toBe("codex");
expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({
provider: PROVIDER_ID,
config: { model: "shutdown-model" },
});
await expect await expect
.poll(async () => { .poll(async () => {
const logs = await client.getPluginLogs("shutdown-provider-plugin"); const logs = await client.getPluginLogs("agent-hooks");
return logs return logs
.filter((entry) => { .filter((entry) => {
return entry.message.startsWith('{"hook":'); return entry.message.startsWith('{"hook":');
@@ -287,17 +218,10 @@ test("agent creation hooks change the provider and environment before the sessio
}) })
.toMatchObject([ .toMatchObject([
{ {
hook: "agent.open.before", hook: "agent.session_open",
request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, 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); await client.archiveAgent(agent.id);
} finally { } finally {
@@ -320,10 +244,10 @@ test("invalid output from an untyped plugin rejects creation before later callba
path.join(directory, "index.server.ts"), path.join(directory, "index.server.ts"),
` `
export default function contribute(server) { export default function contribute(server) {
server.addHook("workspace.create.before", async () => { server.before("workspace.create", () => {
return { source: { kind: "worktree", branchName: 42 } }; return { source: { kind: "worktree", branchName: 42 } };
}); });
server.addHook("workspace.create.before", async ({ request }) => { server.before("workspace.create", ({ request }) => {
console.log("Unexpected later callback"); console.log("Unexpected later callback");
return request; return request;
}); });
@@ -336,7 +260,7 @@ export default function contribute(server) {
await client.installDirectoryPlugin(directory); await client.installDirectoryPlugin(directory);
const result = await client.createWorkspace({ source: { kind: "directory", path: directory } }); const result = await client.createWorkspace({ source: { kind: "directory", path: directory } });
expect(result.workspace).toBeNull(); expect(result.workspace).toBeNull();
expect(result.error).toContain("Plugin invalid-hook workspace.create.before failed"); expect(result.error).toContain("Plugin invalid-hook before workspace.create failed");
const logs = await client.getPluginLogs("invalid-hook"); const logs = await client.getPluginLogs("invalid-hook");
expect( expect(
logs.some((entry) => { logs.some((entry) => {
@@ -354,9 +278,7 @@ const PROVIDER_ID = "shutdown-provider";
async function createProviderPlugin( async function createProviderPlugin(
root: string, root: string,
options: { acknowledgeClose: boolean; hooks?: string; reportOpen?: boolean } = { options: { acknowledgeClose: boolean } = { acknowledgeClose: true },
acknowledgeClose: true,
},
): Promise<string> { ): Promise<string> {
const directory = path.join(root, "plugin"); const directory = path.join(root, "plugin");
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true });
@@ -402,7 +324,6 @@ const provider: ProviderRegistration = {
return; return;
} }
if (input.type === "session.open") { if (input.type === "session.open") {
if (${JSON.stringify(options.reportOpen ?? false)}) console.log(JSON.stringify({ hook: "provider.open", config: input.config }));
emit({ emit({
type: "session.opened", type: "session.opened",
requestId: input.requestId, requestId: input.requestId,
@@ -454,7 +375,6 @@ const provider: ProviderRegistration = {
export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) { export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) {
server.registerProvider(provider); server.registerProvider(provider);
${options.hooks ?? ""}
return () => undefined; return () => undefined;
}`, }`,
); );
@@ -463,7 +383,7 @@ ${options.hooks ?? ""}
test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => { 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-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root); const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -507,7 +427,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 () => { test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root); const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -556,7 +476,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 () => { 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-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-"));
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); onTestFinished(() => rm(root, { recursive: true, force: true }));
const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false }); const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false });
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -666,644 +586,3 @@ test("reloading a plugin does not send on a closed IPC channel", async () => {
await rm(pluginDirectory, { recursive: true, force: true }); await rm(pluginDirectory, { recursive: true, force: true });
} }
}, 60_000); }, 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);
@@ -6,7 +6,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import pino from "pino"; import pino from "pino";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import type { PluginHookRequests, PluginHookEvents } from "@getpaseo/plugin/server"; import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
@@ -14,14 +14,14 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
type RecordedHook = type RecordedHook =
| { | {
[Name in keyof PluginHookEvents]: { hook: Name; data: PluginHookEvents[Name] }; [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] };
}[keyof PluginHookEvents] }[keyof PluginLifecycleEvents]
| { | {
[Name in keyof PluginHookRequests]: { [Name in keyof PluginBeforeRequests]: {
hook: Name; hook: `before ${Name}`;
data: { request: PluginHookRequests[Name] }; data: { request: PluginBeforeRequests[Name] };
}; };
}[keyof PluginHookRequests]; }[keyof PluginBeforeRequests];
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof");
@@ -39,8 +39,8 @@ async function readHooks(client: DaemonClient): Promise<RecordedHook[]> {
async function endedTurns(client: DaemonClient, agentId: string) { async function endedTurns(client: DaemonClient, agentId: string) {
const events = await readHooks(client); const events = await readHooks(client);
return events.filter((event): event is Extract<RecordedHook, { hook: "agent.turn.after" }> => { return events.filter((event): event is Extract<RecordedHook, { hook: "agent.turn_ended" }> => {
return event.hook === "agent.turn.after" && event.data.agent.id === agentId; return event.hook === "agent.turn_ended" && event.data.agent.id === agentId;
}); });
} }
@@ -63,7 +63,7 @@ async function waitForPermission(client: DaemonClient, agentId: string, command:
const events = await readHooks(client); const events = await readHooks(client);
for (const event of events) { for (const event of events) {
if ( if (
event.hook === "agent.permission.before" && event.hook === "agent.permission_requested" &&
event.data.agent.id === agentId && event.data.agent.id === agentId &&
event.data.request.input?.command === command event.data.request.input?.command === command
) { ) {
@@ -111,7 +111,7 @@ test.skipIf(process.platform !== "linux")(
}); });
const logger = pino({ level: "info" }, destination); const logger = pino({ level: "info" }, destination);
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.1", daemonVersion: "0.8.0",
logger, logger,
agentClients: { agentClients: {
claude: new ClaudeAgentClient({ logger }), claude: new ClaudeAgentClient({ logger }),
@@ -121,7 +121,7 @@ test.skipIf(process.platform !== "linux")(
}); });
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`, url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.1", appVersion: "0.8.0",
}); });
const proof: Record<string, unknown> = { const proof: Record<string, unknown> = {
port: daemon.port, port: daemon.port,
@@ -169,7 +169,7 @@ test.skipIf(process.platform !== "linux")(
const approvalId = await waitForPermission(client, agent.id, "git status"); const approvalId = await waitForPermission(client, agent.id, "git status");
expect(await readHooks(client)).toContainEqual( expect(await readHooks(client)).toContainEqual(
expect.objectContaining({ expect.objectContaining({
hook: "agent.permission.after", hook: "agent.permission_resolved",
data: expect.objectContaining({ data: expect.objectContaining({
requestId: approvalId, requestId: approvalId,
resolution: expect.objectContaining({ behavior: "allow" }), 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"); const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt");
expect(await readHooks(client)).toContainEqual( expect(await readHooks(client)).toContainEqual(
expect.objectContaining({ expect.objectContaining({
hook: "agent.permission.after", hook: "agent.permission_resolved",
data: expect.objectContaining({ data: expect.objectContaining({
requestId: deniedId, requestId: deniedId,
resolution: expect.objectContaining({ behavior: "deny" }), resolution: expect.objectContaining({ behavior: "deny" }),
@@ -224,7 +224,7 @@ test.skipIf(process.platform !== "linux")(
.poll( .poll(
async () => { async () => {
return (await readHooks(client)).filter((event) => { return (await readHooks(client)).filter((event) => {
return event.hook === "agent.turn.before" && event.data.agent.id === agent.id; return event.hook === "agent.turn_started" && event.data.agent.id === agent.id;
}).length; }).length;
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -278,26 +278,23 @@ test.skipIf(process.platform !== "linux")(
); );
return [...names].sort(); return [...names].sort();
}) })
.toEqual( .toEqual([
[ "agent.archived",
"agent.archive.after", "agent.closed",
"agent.close.after", "agent.created",
"agent.create.after", "agent.permission_requested",
"agent.permission.before", "agent.permission_resolved",
"agent.permission.after", "agent.turn_ended",
"agent.turn.after", "agent.turn_started",
"agent.turn.before", "before agent.create",
"agent.create.before", "before agent.session_open",
"agent.open.before", "before workspace.create",
"worktree.create.after", "workspace.archived",
"workspace.create.before", "workspace.created",
"workspace.archive.after", ]);
"workspace.create.after",
].sort(),
);
const openReasons = (await readHooks(client)) const openReasons = (await readHooks(client))
.filter((event) => { .filter((event) => {
return event.hook === "agent.open.before"; return event.hook === "before agent.session_open";
}) })
.map((event) => { .map((event) => {
return event.data.request.reason; return event.data.request.reason;
@@ -1,7 +1,7 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { createPaseoApi } from "@getpaseo/client"; import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { validateBeforeResult, PluginHookHandlers } from "./index.js"; import { PluginHookHandlers } from "./index.js";
const paseo = createPaseoApi( const paseo = createPaseoApi(
new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }),
@@ -9,17 +9,18 @@ const paseo = createPaseoApi(
test("removing an old registration twice preserves a newer registration for the same hook", async () => { test("removing an old registration twice preserves a newer registration for the same hook", async () => {
const hooks = new PluginHookHandlers(() => {}); const hooks = new PluginHookHandlers(() => {});
const remove = hooks.addHook("workspace.create.before", async ({ request }) => { const remove = hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "old" }; return { ...request, title: "old" };
}); });
remove(); remove();
hooks.addHook("workspace.create.before", async ({ request }) => { hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "new" }; return { ...request, title: "new" };
}); });
remove(); remove();
const output = await hooks.invoke( const output = await hooks.invoke(
"operation", "operation",
"workspace.create.before", "before",
"workspace.create",
{ {
source: { kind: "directory", path: "/project" }, source: { kind: "directory", path: "/project" },
}, },
@@ -30,17 +31,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 () => { test("before hooks compose returned requests and preserve the original input", async () => {
const hooks = new PluginHookHandlers(() => {}); const hooks = new PluginHookHandlers(() => {});
hooks.addHook("workspace.create.before", async ({ request }) => { hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "first" }; return { ...request, title: "first" };
}); });
hooks.addHook("workspace.create.before", async () => { hooks.before("workspace.create", () => {
return; return;
}); });
hooks.addHook("workspace.create.before", async ({ request }) => { hooks.before("workspace.create", ({ request }) => {
return { ...request, title: request.title + ":second" }; return { ...request, title: request.title + ":second" };
}); });
const input = { source: { kind: "directory", path: "/project" } }; const input = { source: { kind: "directory", path: "/project" } };
expect(await hooks.invoke("operation", "workspace.create.before", input, paseo)).toEqual({ expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({
source: { kind: "directory", path: "/project" }, source: { kind: "directory", path: "/project" },
title: "first:second", title: "first:second",
}); });
@@ -49,7 +50,7 @@ test("before hooks compose returned requests and preserve the original input", a
test("teardown aborts an active callback and removes its registrations", async () => { test("teardown aborts an active callback and removes its registrations", async () => {
const hooks = new PluginHookHandlers(() => {}); const hooks = new PluginHookHandlers(() => {});
hooks.addHook("workspace.create.before", async (_input, context) => { hooks.before("workspace.create", async (_input, context) => {
await new Promise<void>((_resolve, reject) => { await new Promise<void>((_resolve, reject) => {
context.signal.addEventListener( context.signal.addEventListener(
"abort", "abort",
@@ -62,7 +63,8 @@ test("teardown aborts an active callback and removes its registrations", async (
}); });
const invocation = hooks.invoke( const invocation = hooks.invoke(
"operation", "operation",
"workspace.create.before", "before",
"workspace.create",
{ {
source: { kind: "directory", path: "/project" }, source: { kind: "directory", path: "/project" },
}, },
@@ -70,18 +72,19 @@ test("teardown aborts an active callback and removes its registrations", async (
); );
hooks.close(); hooks.close();
await expect(invocation).rejects.toThrow("Hook aborted"); await expect(invocation).rejects.toThrow("Hook aborted");
expect(hooks.catalog()).toEqual([]); expect(hooks.catalog()).toEqual({ events: [], before: [] });
}); });
test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => { test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => {
const hooks = new PluginHookHandlers(() => {}); const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => { hooks.before("agent.session_open", ({ request }) => {
return { ...request, provider: "another-provider" }; return { ...request, provider: "another-provider" };
}); });
await expect( await expect(
hooks.invoke( hooks.invoke(
"operation", "operation",
"agent.open.before", "before",
"agent.session_open",
{ {
agentId: "agent", agentId: "agent",
workspaceId: "workspace", workspaceId: "workspace",
@@ -97,18 +100,19 @@ test("session-open hooks reject changes to session identity instead of silently
}, },
paseo, paseo,
), ),
).rejects.toThrow("agent.open.before hooks can only change env"); ).rejects.toThrow("agent.session_open hooks can only change env");
}); });
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j", "session-open targeting metadata is read-only: %j",
async (patch) => { async (patch) => {
const hooks = new PluginHookHandlers(() => {}); const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({ ...request, ...patch })); hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch }));
await expect( await expect(
hooks.invoke( hooks.invoke(
"operation", "operation",
"agent.open.before", "before",
"agent.session_open",
{ {
agentId: "agent", agentId: "agent",
workspaceId: null, workspaceId: null,
@@ -124,132 +128,6 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
}, },
paseo, paseo,
), ),
).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers"); ).rejects.toThrow("agent.session_open 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/);
});
@@ -1,8 +1,3 @@
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import {
createLifecycleRegistration,
pluginHookRegistry,
} from "@getpaseo/plugin/internal/lifecycle";
import { isDeepStrictEqual } from "node:util"; import { isDeepStrictEqual } from "node:util";
import { import {
serializeAgentTool, serializeAgentTool,
@@ -16,83 +11,43 @@ import { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
import type { import type {
PluginHookAgent, PluginHookAgent,
PluginHookPayload,
PluginHookContext, PluginHookContext,
PluginLifecycleRegistration, PluginLifecycleRegistration,
} from "@getpaseo/plugin/server"; } from "@getpaseo/plugin/server";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { import type {
PluginHookRequests, PluginBeforeRequests,
PluginHookWorkspace, PluginHookWorkspace,
PluginHookEvents, PluginLifecycleEvents,
} from "@getpaseo/plugin/server"; } from "@getpaseo/plugin/server";
import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
export type PluginWireBeforeRequests = Omit<PluginHookRequests, "agent.open.before"> & { export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session_open"> & {
"agent.open.before": SessionOpenWireRequest; "agent.session_open": SessionOpenWireRequest;
}; };
/** Daemon-only results; ownership never crosses the plugin process boundary. */ /** Daemon-only results; ownership never crosses the plugin process boundary. */
export type PluginBeforeResults = PluginWireBeforeRequests & { export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> }; "agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
}; };
const worktreeRequestFields = { export const lifecycleEventNames = [
repoRoot: z.string(), "agent.created",
sourceCheckout: z.string(), "agent.closed",
branch: z.string().min(1), "agent.turn_started",
slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"), "agent.turn_ended",
path: z.string().min(1), "agent.permission_requested",
}; "agent.permission_resolved",
const changeRequestFields = { "agent.archived",
headRef: z.string().min(1), "workspace.created",
headRepositoryOwner: z.string().optional(), "workspace.archived",
checkoutRefs: z ] as const;
.array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() })) export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const;
.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 = { const beforeSchemas = {
"worktree.create.before": worktreeRequestSchema, "agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(),
"agent.create.before": CreateAgentRequestMessageSchema.pick({ "agent.session_open": z
config: true,
env: true,
}).strict(),
"agent.open.before": z
.object({ .object({
agentId: z.string(), agentId: z.string(),
workspaceId: z.string().nullable(), workspaceId: z.string().nullable(),
@@ -107,13 +62,10 @@ const beforeSchemas = {
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
}) })
.strict(), .strict(),
"workspace.create.before": WorkspaceCreateRequestSchema.omit({ "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
type: true,
requestId: true,
}).strict(),
}; };
const sessionOpenLocalResultSchema = beforeSchemas["agent.open.before"].extend({ const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({
tools: z.array( tools: z.array(
z.custom<AgentToolDefinition>( z.custom<AgentToolDefinition>(
(tool) => (tool) =>
@@ -127,11 +79,10 @@ const sessionOpenLocalResultSchema = beforeSchemas["agent.open.before"].extend({
}); });
export interface PluginLifecycle { export interface PluginLifecycle {
wait( emit<Name extends keyof PluginLifecycleEvents>(
name: "worktree.create.after", name: Name,
payload: PluginHookPayload<"worktree.create.after">, event: PluginLifecycleEvents[Name],
): Promise<void>; ): void;
emit<Name extends keyof PluginHookEvents>(name: Name, event: PluginHookEvents[Name]): void;
before<Name extends keyof PluginWireBeforeRequests>( before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
request: PluginWireBeforeRequests[Name], request: PluginWireBeforeRequests[Name],
@@ -179,32 +130,32 @@ export function publishAgentStream(
event: AgentStreamEvent, event: AgentStreamEvent,
): void { ): void {
if (event.type === "turn_started") { if (event.type === "turn_started") {
lifecycle.emit("agent.turn.before", { agent, turnId: event.turnId ?? null }); lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null });
} else if (event.type === "turn_completed") { } else if (event.type === "turn_completed") {
lifecycle.emit("agent.turn.after", { lifecycle.emit("agent.turn_ended", {
agent, agent,
turnId: event.turnId ?? null, turnId: event.turnId ?? null,
timeline: [], timeline: [],
outcome: { kind: "completed" }, outcome: { kind: "completed" },
}); });
} else if (event.type === "turn_failed") { } else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn.after", { lifecycle.emit("agent.turn_ended", {
agent, agent,
turnId: event.turnId ?? null, turnId: event.turnId ?? null,
timeline: [], timeline: [],
outcome: { kind: "failed", error: { message: event.error, code: event.code } }, outcome: { kind: "failed", error: { message: event.error, code: event.code } },
}); });
} else if (event.type === "turn_canceled") { } else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn.after", { lifecycle.emit("agent.turn_ended", {
agent, agent,
turnId: event.turnId ?? null, turnId: event.turnId ?? null,
timeline: [], timeline: [],
outcome: { kind: "canceled", reason: event.reason }, outcome: { kind: "canceled", reason: event.reason },
}); });
} else if (event.type === "permission_requested") { } else if (event.type === "permission_requested") {
lifecycle.emit("agent.permission.before", { agent, request: event.request }); lifecycle.emit("agent.permission_requested", { agent, request: event.request });
} else if (event.type === "permission_resolved") { } else if (event.type === "permission_resolved") {
lifecycle.emit("agent.permission.after", { lifecycle.emit("agent.permission_resolved", {
agent, agent,
requestId: event.requestId, requestId: event.requestId,
resolution: event.resolution, resolution: event.resolution,
@@ -212,15 +163,15 @@ export function publishAgentStream(
} }
} }
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>( export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginWireBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
output: unknown, output: unknown,
): Promise<PluginWireBeforeRequests[Name]> { ): PluginWireBeforeRequests[Name] {
const result = validateBeforeRequest(name, output); const result = validateBeforeRequest(name, output);
if (name === "agent.open.before") { if (name === "agent.session_open") {
const previous = beforeSchemas["agent.open.before"].parse(input); const previous = beforeSchemas["agent.session_open"].parse(input);
const next = beforeSchemas["agent.open.before"].parse(result); const next = beforeSchemas["agent.session_open"].parse(result);
if ( if (
previous.agentId !== next.agentId || previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId || previous.workspaceId !== next.workspaceId ||
@@ -231,21 +182,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
previous.title !== next.title || previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels) !isDeepStrictEqual(previous.labels, next.labels)
) { ) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers"); throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
} }
} }
if (name === "agent.create.before") { if (name === "agent.create") {
const previous = beforeSchemas["agent.create.before"].parse(input); const previous = beforeSchemas["agent.create"].parse(input);
const next = beforeSchemas["agent.create.before"].parse(result); const next = beforeSchemas["agent.create"].parse(result);
if (previous.config.cwd !== next.config.cwd) { if (previous.config.cwd !== next.config.cwd) {
throw new Error("agent.create.before hooks cannot change the workspace directory"); throw new Error("agent.create hooks cannot change the workspace directory");
}
}
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);
const next = beforeSchemas["worktree.create.before"].parse(result);
if (previous.repoRoot !== next.repoRoot || previous.sourceCheckout !== next.sourceCheckout) {
throw new Error("worktree.create.before hooks cannot change repoRoot or sourceCheckout");
} }
} }
return result; return result;
@@ -254,7 +198,8 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
type Handler = (input: unknown, context: PluginHookContext) => unknown; type Handler = (input: unknown, context: PluginHookContext) => unknown;
export class PluginHookHandlers implements PluginLifecycleRegistration { export class PluginHookHandlers implements PluginLifecycleRegistration {
private readonly handlers = new Map<string, Set<Handler>>(); private readonly events = new Map<string, Set<Handler>>();
private readonly transforms = new Map<string, Set<Handler>>();
private readonly active = new Map<string, AbortController>(); private readonly active = new Map<string, AbortController>();
private readonly changed: () => void; private readonly changed: () => void;
@@ -264,24 +209,29 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
constructor(changed: () => void, agentTools?: PluginAgentTools) { constructor(changed: () => void, agentTools?: PluginAgentTools) {
this.agentTools = agentTools; this.agentTools = agentTools;
this.changed = changed; 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 addHook: PluginLifecycleRegistration["addHook"]; readonly on: PluginLifecycleRegistration["on"] = (name, handler) => {
readonly on: PluginLifecycleRegistration["on"]; if (!lifecycleEventNames.includes(name)) {
readonly before: PluginLifecycleRegistration["before"]; throw new Error(`Unknown lifecycle event: ${name}`);
}
return this.register(this.events, name, handler as Handler);
};
catalog(): string[] { readonly before: PluginLifecycleRegistration["before"] = (name, handler) => {
return [...this.handlers.keys()]; 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()] };
} }
async invoke( async invoke(
id: string, id: string,
kind: "event" | "before",
name: string, name: string,
input: unknown, input: unknown,
paseo: PluginHookContext["paseo"], paseo: PluginHookContext["paseo"],
@@ -289,32 +239,34 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
const controller = new AbortController(); const controller = new AbortController();
this.active.set(id, controller); this.active.set(id, controller);
try { try {
if (Object.hasOwn(beforeSchemas, name)) { if (kind === "before") {
const hookName = name as keyof PluginHookRequests; if (
if (hookName === "agent.open.before") { !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") {
return await this.openSession(input, { paseo, signal: controller.signal }); return await this.openSession(input, { paseo, signal: controller.signal });
} }
let request = validateBeforeRequest(hookName, input); let request = validateBeforeRequest(hookName, input);
for (const handler of this.handlers.get(name) ?? []) { for (const handler of this.transforms.get(name) ?? []) {
controller.signal.throwIfAborted(); controller.signal.throwIfAborted();
const result = await handler( const result = await handler(
{ request: structuredClone(request) }, { request: structuredClone(request) },
{ paseo, signal: controller.signal }, { paseo, signal: controller.signal },
); );
if (result !== undefined) request = await validateBeforeResult(hookName, request, result); if (result !== undefined) request = validateBeforeResult(hookName, request, result);
} }
return request; return request;
} }
for (const handler of this.handlers.get(name) ?? []) { for (const handler of this.events.get(name) ?? []) {
controller.signal.throwIfAborted(); controller.signal.throwIfAborted();
try { try {
await handler(structuredClone(input), { paseo, signal: controller.signal }); await handler(structuredClone(input), { paseo, signal: controller.signal });
} catch (error) { } catch (error) {
if (
Object.hasOwn(pluginHookRegistry, name) &&
pluginHookRegistry[name as keyof typeof pluginHookRegistry].waits
)
throw error;
console.error(`Lifecycle hook ${name} failed`, error); console.error(`Lifecycle hook ${name} failed`, error);
} }
} }
@@ -328,10 +280,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
input: unknown, input: unknown,
context: PluginHookContext, context: PluginHookContext,
): Promise<SessionOpenWireRequest> { ): Promise<SessionOpenWireRequest> {
let request = validateBeforeRequest("agent.open.before", input); let request = validateBeforeRequest("agent.session_open", input);
this.agentTools?.detach(request.agentId); this.agentTools?.detach(request.agentId);
let tools: AgentToolDefinition[] = []; let tools: AgentToolDefinition[] = [];
for (const handler of this.handlers.get("agent.open.before") ?? []) { for (const handler of this.transforms.get("agent.session_open") ?? []) {
context.signal.throwIfAborted(); context.signal.throwIfAborted();
const result = await handler( const result = await handler(
{ request: { ...structuredClone(request), tools: [...tools] } }, { request: { ...structuredClone(request), tools: [...tools] } },
@@ -340,10 +292,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
if (result === undefined) continue; if (result === undefined) continue;
const local = sessionOpenLocalResultSchema.parse(result); const local = sessionOpenLocalResultSchema.parse(result);
const metadata = local.tools.map(serializeAgentTool); const metadata = local.tools.map(serializeAgentTool);
request = await validateBeforeResult("agent.open.before", request, { request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata });
...local,
tools: metadata,
});
tools = local.tools; tools = local.tools;
} }
context.signal.throwIfAborted(); context.signal.throwIfAborted();
@@ -362,14 +311,19 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
for (const controller of this.active.values()) { for (const controller of this.active.values()) {
controller.abort(); controller.abort();
} }
this.handlers.clear(); this.events.clear();
this.transforms.clear();
} }
private register(name: string, handler: Handler): () => void { private register(
let handlers = this.handlers.get(name); registry: Map<string, Set<Handler>>,
name: string,
handler: Handler,
): () => void {
let handlers = registry.get(name);
if (!handlers) { if (!handlers) {
handlers = new Set(); handlers = new Set();
this.handlers.set(name, handlers); registry.set(name, handlers);
} }
handlers.add(handler); handlers.add(handler);
this.changed(); this.changed();
@@ -378,7 +332,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
return; return;
} }
if (handlers.size === 0) { if (handlers.size === 0) {
this.handlers.delete(name); registry.delete(name);
} }
this.changed(); this.changed();
}; };
@@ -55,7 +55,7 @@ export type PluginProcessRequest =
providerId: string; providerId: string;
options: ProviderCatalogOptions; options: ProviderCatalogOptions;
} }
| { type: "hook"; requestId: string; name: string; input: unknown } | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown }
| { type: "hook.cancel"; requestId: string } | { type: "hook.cancel"; requestId: string }
| { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown }
| { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope }
@@ -79,13 +79,13 @@ export type PluginProcessRequest =
export type PluginProcessMessage = export type PluginProcessMessage =
| { type: "settings.changed"; settingsId: string } | { type: "settings.changed"; settingsId: string }
| { type: "hooks.changed"; hooks: string[] } | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } }
| { | {
type: "ready"; type: "ready";
methods: string[]; methods: string[];
providers: PluginProviderMetadata[]; providers: PluginProviderMetadata[];
usageSources?: PluginUsageSourceMetadata[]; usageSources?: PluginUsageSourceMetadata[];
hooks?: string[]; hooks?: { events: string[]; before: string[] };
} }
| { type: "result"; requestId: string; output: unknown } | { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string } | { type: "error"; requestId: string; error: string }
@@ -109,7 +109,7 @@ export type PluginProcessMessage =
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
| { type: "paseo_close" }; | { type: "paseo_close" };
const hooksSchema = z.array(z.string()); const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict();
const providerMetadataSchema = z const providerMetadataSchema = z
.object({ .object({
@@ -211,6 +211,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
.object({ .object({
type: z.literal("hook"), type: z.literal("hook"),
requestId: z.string(), requestId: z.string(),
kind: z.enum(["event", "before"]),
name: z.string(), name: z.string(),
input: z.unknown(), input: z.unknown(),
}) })
@@ -288,7 +288,6 @@ export function createPluginWorker(options: {
registerProvider, registerProvider,
registerUsageSource, registerUsageSource,
registerSettings, registerSettings,
addHook: hooks.addHook,
on: hooks.on, on: hooks.on,
before: hooks.before, before: hooks.before,
}); });
@@ -564,7 +563,7 @@ export function createPluginWorker(options: {
}); });
return; return;
} }
void hooks.invoke(message.requestId, message.name, message.input, paseo).then( void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then(
(output) => { (output) => {
return send({ type: "result", requestId: message.requestId, output }); return send({ type: "result", requestId: message.requestId, output });
}, },
@@ -11,7 +11,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js";
import { PluginRuntime } from "./runtime.js"; import { PluginRuntime } from "./runtime.js";
import { resolveDaemonVersion } from "../daemon-version.js";
import type { PluginSessionSocket } from "./session-socket.js"; import type { PluginSessionSocket } from "./session-socket.js";
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -2079,11 +2078,7 @@ const examples = (await readdir(examplesDirectory, { withFileTypes: true }))
it.each(examples)("builds and loads the %s plugin example", async (example) => { it.each(examples)("builds and loads the %s plugin example", async (example) => {
const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-"));
temporaryDirectories.push(settingsDirectory); temporaryDirectories.push(settingsDirectory);
const runtime = createTestRuntime( const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2");
{ settingsDirectory },
undefined,
resolveDaemonVersion(import.meta.url),
);
try { try {
await runtime.startPlugin(example, path.join(examplesDirectory, example)); await runtime.startPlugin(example, path.join(examplesDirectory, example));
expect(runtime.catalog()).toMatchObject([{ id: example }]); expect(runtime.catalog()).toMatchObject([{ id: example }]);
+26 -44
View File
@@ -1,5 +1,3 @@
import { validateWorktreePath } from "../../utils/worktree-path.js";
import type { PluginHookPayload } from "@getpaseo/plugin/server";
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage"; import type { UsageScope } from "@getpaseo/plugin/server/usage";
@@ -8,7 +6,11 @@ import {
type ProviderStatus, type ProviderStatus,
type ProviderStatusRequest, type ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider"; } from "@getpaseo/plugin/server/provider";
import type { PluginHookEvents, PluginHookAgent, AgentToolResult } from "@getpaseo/plugin/server"; import type {
PluginLifecycleEvents,
PluginHookAgent,
AgentToolResult,
} from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import path from "node:path"; import path from "node:path";
@@ -75,7 +77,7 @@ interface LoadedPlugin {
/** Lets a client that already holds this bundle skip downloading it again. */ /** Lets a client that already holds this bundle skip downloading it again. */
clientBundleHash: string; clientBundleHash: string;
methods: ReadonlySet<string>; methods: ReadonlySet<string>;
hooks: string[]; hooks: { events: string[]; before: string[] };
providers: readonly PluginProviderMetadata[]; providers: readonly PluginProviderMetadata[];
usageSources: readonly PluginUsageSourceMetadata[]; usageSources: readonly PluginUsageSourceMetadata[];
child: PluginChild | null; child: PluginChild | null;
@@ -309,7 +311,7 @@ export class PluginRuntime {
} }
private notifySessionOpenChange(loaded: LoadedPlugin): void { private notifySessionOpenChange(loaded: LoadedPlugin): void {
if (!loaded.hooks.includes("agent.open.before")) return; if (!loaded.hooks.before.includes("agent.session_open")) return;
for (const listener of this.sessionOpenListeners) listener(); for (const listener of this.sessionOpenListeners) listener();
} }
@@ -514,14 +516,18 @@ export class PluginRuntime {
return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input });
} }
emit<Name extends keyof PluginHookEvents>(name: Name, event: PluginHookEvents[Name]): void { emit<Name extends keyof PluginLifecycleEvents>(
name: Name,
event: PluginLifecycleEvents[Name],
): void {
for (const loaded of this.plugins.values()) { for (const loaded of this.plugins.values()) {
if (!loaded.hooks.includes(name)) { if (!loaded.hooks.events.includes(name)) {
continue; continue;
} }
const request = this.request(loaded, { const request = this.request(loaded, {
type: "hook", type: "hook",
requestId: randomUUID(), requestId: randomUUID(),
kind: "event",
name, name,
input: event, input: event,
}) })
@@ -542,25 +548,6 @@ export class PluginRuntime {
await Promise.all(this.pendingEvents); await Promise.all(this.pendingEvents);
} }
async wait(
name: "worktree.create.after",
payload: PluginHookPayload<"worktree.create.after">,
): Promise<void> {
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 extends keyof PluginWireBeforeRequests>( async before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginWireBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
@@ -573,38 +560,33 @@ export class PluginRuntime {
const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; const mcpServers: SessionOpenWireRequest["mcpServers"] = {};
const mcpServerOwners: Record<string, string> = {}; const mcpServerOwners: Record<string, string> = {};
for (const loaded of plugins) { for (const loaded of plugins) {
if (!loaded.hooks.includes(name)) { if (!loaded.hooks.before.includes(name)) {
continue; continue;
} }
try { try {
const output = await this.request(loaded, { const output = await this.request(loaded, {
type: "hook", type: "hook",
requestId: randomUUID(), requestId: randomUUID(),
kind: "before",
name, name,
input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request, input:
name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request,
}); });
request = await validateBeforeResult(name, request, output); request = validateBeforeResult(name, request, output);
if (name === "worktree.create.before") { if (name === "agent.session_open") {
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; const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
this.collectAgentTools(attached, loaded, opened.tools); this.collectAgentTools(attached, loaded, opened.tools);
} }
} catch (error) { } catch (error) {
if (name === "agent.open.before") if (name === "agent.session_open")
this.detachTools((request as SessionOpenWireRequest).agentId); this.detachTools((request as SessionOpenWireRequest).agentId);
throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, {
cause: error, cause: error,
}); });
} }
} }
if (name === "agent.open.before") { if (name === "agent.session_open") {
return { return {
...request, ...request,
tools: attached, tools: attached,
@@ -781,7 +763,7 @@ export class PluginRuntime {
clientBundleHash: hashClientBundle(clientBundle), clientBundleHash: hashClientBundle(clientBundle),
requirements: manifest.requirements, requirements: manifest.requirements,
methods: new Set(), methods: new Set(),
hooks: [], hooks: { events: [], before: [] },
providers: [], providers: [],
usageSources: [], usageSources: [],
child: null, child: null,
@@ -911,7 +893,7 @@ export class PluginRuntime {
clientBundleHash: hashClientBundle(clientBundle), clientBundleHash: hashClientBundle(clientBundle),
requirements, requirements,
methods: new Set(ready.methods), methods: new Set(ready.methods),
hooks: ready.hooks ?? [], hooks: ready.hooks ?? { events: [], before: [] },
providers: ready.providers ?? [], providers: ready.providers ?? [],
usageSources: ready.usageSources ?? [], usageSources: ready.usageSources ?? [],
child, child,
@@ -991,11 +973,11 @@ export class PluginRuntime {
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "hooks.changed") { if (message.type === "hooks.changed") {
const hadSessionOpen = loaded.hooks.includes("agent.open.before"); const hadSessionOpen = loaded.hooks.before.includes("agent.session_open");
loaded.hooks = message.hooks; loaded.hooks = message.hooks;
if ( if (
this.plugins.get(loaded.id) === loaded && this.plugins.get(loaded.id) === loaded &&
hadSessionOpen !== loaded.hooks.includes("agent.open.before") hadSessionOpen !== loaded.hooks.before.includes("agent.session_open")
) { ) {
for (const listener of this.sessionOpenListeners) listener(); for (const listener of this.sessionOpenListeners) listener();
} }
+1 -88
View File
@@ -8,15 +8,7 @@ import {
createTestCreationService, createTestCreationService,
} from "./test-utils/session-stubs.js"; } from "./test-utils/session-stubs.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join, resolve as resolvePath } from "path"; import { join, resolve as resolvePath } from "path";
import pino from "pino"; import pino from "pino";
@@ -36,11 +28,6 @@ import {
type FileTransferFrame, type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import { Session } from "./session.js"; 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 { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js";
import { DownloadTokenStore } from "./file-download/token-store.js"; import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
@@ -2084,84 +2071,11 @@ describe("daemon status + pairing RPC", () => {
{ provider: "claude", available: true, error: null }, { provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" }, { 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 () => { test("daemon.get_status.request falls back to a null/empty status when provider listing fails", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const session = createSessionForTest({ const session = createSessionForTest({
@@ -2193,7 +2107,6 @@ describe("daemon status + pairing RPC", () => {
listen: null, listen: null,
relay: null, relay: null,
providers: [], providers: [],
terminalCleanupUnconfirmed: [],
}, },
}, },
]); ]);

Some files were not shown because too many files have changed in this diff Show More