Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1467121b16 | ||
|
|
2f0fb2a8ba | ||
|
|
83ed52669d | ||
|
|
5cfe8f1218 | ||
|
|
38506ed372 | ||
|
|
abc97e0545 | ||
|
|
33faacc055 | ||
|
|
8537709e72 | ||
|
|
9fb7808191 | ||
|
|
41582eb5fd | ||
|
|
dee0d138e9 | ||
|
|
986687b8fd | ||
|
|
eee70e7fef | ||
|
|
d5a27e0164 | ||
|
|
8e7e8cce21 | ||
|
|
36fc4f0b43 | ||
|
|
4dae66eb8c | ||
|
|
d6d5a266ad | ||
|
|
460ba8e8a3 | ||
|
|
7a0c6fbd82 | ||
|
|
bede2b90ed | ||
|
|
befc3d5663 | ||
|
|
53bbd7522d | ||
|
|
ce412d04c3 | ||
|
|
de45902652 | ||
|
|
abc23d7721 | ||
|
|
2c23cd4927 | ||
|
|
de50243885 | ||
|
|
5b7b8d6e81 | ||
|
|
25721c7847 | ||
|
|
78f580f9e8 |
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: store-screenshots
|
||||||
|
description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed.
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Store screenshots
|
||||||
|
|
||||||
|
Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports.
|
||||||
|
|
||||||
|
Working rules:
|
||||||
|
|
||||||
|
- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store.
|
||||||
|
- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type.
|
||||||
|
- Use SF Pro. Never substitute another font.
|
||||||
|
- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview.
|
||||||
|
- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks.
|
||||||
|
- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons.
|
||||||
|
- When you learn something the next capture needs, fix the README rather than this skill.
|
||||||
@@ -203,6 +203,31 @@
|
|||||||
"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.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
## 0.14.0-beta.2 - 2026-10-28
|
## 0.14.0-beta.2 - 2026-10-28
|
||||||
|
|
||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||||
|
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||||
|
|
||||||
|
改进:
|
||||||
|
|
||||||
|
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
## 0.14.0-beta.2 - 2026-10-28
|
## 0.14.0-beta.2 - 2026-10-28
|
||||||
|
|
||||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
|||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
| [docs/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 |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
output/preview.png
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Store screenshots
|
||||||
|
|
||||||
|
Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change.
|
||||||
|
|
||||||
|
| # | Headline | Capture | Phone callout | Tablet callout |
|
||||||
|
| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
|
||||||
|
| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) |
|
||||||
|
| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) |
|
||||||
|
| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover |
|
||||||
|
| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) |
|
||||||
|
| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree |
|
||||||
|
|
||||||
|
On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`.
|
||||||
|
|
||||||
|
## Creative direction
|
||||||
|
|
||||||
|
The panels follow paseo.sh:
|
||||||
|
|
||||||
|
- **Canvas:** the site's `#101615` background.
|
||||||
|
- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines.
|
||||||
|
- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height.
|
||||||
|
- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site.
|
||||||
|
- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred.
|
||||||
|
- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain.
|
||||||
|
|
||||||
|
Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups.
|
||||||
|
|
||||||
|
Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts.
|
||||||
|
- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout.
|
||||||
|
- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there.
|
||||||
|
- `demo/`: the staged demo host the captures come from.
|
||||||
|
- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot.
|
||||||
|
- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures.
|
||||||
|
- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot.
|
||||||
|
- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures.
|
||||||
|
- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store.
|
||||||
|
|
||||||
|
All exports are opaque RGB PNGs.
|
||||||
|
|
||||||
|
## Render
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font.
|
||||||
|
|
||||||
|
Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased.
|
||||||
|
|
||||||
|
Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing.
|
||||||
|
|
||||||
|
Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it.
|
||||||
|
|
||||||
|
## Recapture
|
||||||
|
|
||||||
|
1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx tsx assets/store-screenshots/demo/stage.mts
|
||||||
|
```
|
||||||
|
|
||||||
|
Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output.
|
||||||
|
|
||||||
|
2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port <port>
|
||||||
|
ssh -o ExitOnForwardFailure=yes -R <port>:127.0.0.1:<port> -R 7791:127.0.0.1:7791 <mac-host>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:<port>`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set.
|
||||||
|
- Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar <device> override`).
|
||||||
|
- On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays.
|
||||||
|
- Name the host "MacBook Pro" and set the sidebar badge to Hidden.
|
||||||
|
- Group the sidebar by status.
|
||||||
|
- If you reset the simulator, reinstall the dev client.
|
||||||
|
|
||||||
|
4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture.
|
||||||
|
|
||||||
|
5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868
|
||||||
|
agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat.
|
||||||
|
- **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks.
|
||||||
|
- **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 <terminal-id> "<text>" Enter` instead of the simulator keyboard, then dismiss the software keyboard.
|
||||||
|
- **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`.
|
||||||
|
|
||||||
|
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||||
|
|
||||||
|
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Applies the demo change to a checkout of the Paseo repository.
|
||||||
|
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repo = process.argv[2];
|
||||||
|
const client = path.join(repo, "packages/client/src");
|
||||||
|
|
||||||
|
copyFileSync(
|
||||||
|
path.join(here, "reconnect-delay.ts"),
|
||||||
|
path.join(client, "connection/reconnect-delay.ts"),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
path.join(here, "reconnect-delay.test.ts"),
|
||||||
|
path.join(client, "connection/reconnect-delay.test.ts"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function edit(file, pairs) {
|
||||||
|
let text = readFileSync(file, "utf8");
|
||||||
|
for (const [from, to] of pairs) {
|
||||||
|
if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`);
|
||||||
|
text = text.replace(from, to);
|
||||||
|
}
|
||||||
|
writeFileSync(file, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(path.join(client, "daemon-client.ts"), [
|
||||||
|
[
|
||||||
|
`import { CreationClient } from "./creation/index.js";\n`,
|
||||||
|
`import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
` private reconnectAttempt = 0;\n`,
|
||||||
|
` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
` nextRetryAt: new Date(
|
||||||
|
Date.now() +
|
||||||
|
Math.min(
|
||||||
|
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
|
||||||
|
2 ** this.reconnectAttempt,
|
||||||
|
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||||
|
),
|
||||||
|
).toISOString(),
|
||||||
|
`,
|
||||||
|
` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(),
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
` private armReconnectTimer(): void {
|
||||||
|
const attempt = this.reconnectAttempt;
|
||||||
|
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
||||||
|
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
||||||
|
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||||||
|
`,
|
||||||
|
` // The countdown and the timer must agree, so each attempt draws its jitter once.
|
||||||
|
private getNextReconnectDelayMs(): number {
|
||||||
|
const attempt = this.reconnectAttempt;
|
||||||
|
if (this.nextReconnectDelay?.attempt !== attempt) {
|
||||||
|
this.nextReconnectDelay = {
|
||||||
|
attempt,
|
||||||
|
delayMs: getReconnectDelayMs(attempt, {
|
||||||
|
baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
|
||||||
|
maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.nextReconnectDelay.delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private armReconnectTimer(): void {
|
||||||
|
const attempt = this.reconnectAttempt;
|
||||||
|
const delay = this.getNextReconnectDelayMs();
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
edit(path.join(repo, "CHANGELOG.md"), [
|
||||||
|
[
|
||||||
|
`# Changelog\n\n`,
|
||||||
|
`# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getReconnectDelayMs } from "./reconnect-delay";
|
||||||
|
|
||||||
|
const options = { baseDelayMs: 1500, maxDelayMs: 30_000 };
|
||||||
|
|
||||||
|
describe("getReconnectDelayMs", () => {
|
||||||
|
it("doubles the ceiling on each attempt", () => {
|
||||||
|
const top = () => 1;
|
||||||
|
expect(getReconnectDelayMs(0, options, top)).toBe(1500);
|
||||||
|
expect(getReconnectDelayMs(1, options, top)).toBe(3000);
|
||||||
|
expect(getReconnectDelayMs(2, options, top)).toBe(6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never waits longer than the max delay", () => {
|
||||||
|
expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spreads retries between half and the full ceiling", () => {
|
||||||
|
expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000);
|
||||||
|
expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000);
|
||||||
|
expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first retry under two seconds", () => {
|
||||||
|
for (let i = 0; i < 100; i += 1) {
|
||||||
|
expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface ReconnectDelayOptions {
|
||||||
|
baseDelayMs: number;
|
||||||
|
maxDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff with equal jitter. Half of each delay is fixed and half
|
||||||
|
* is random, so a relay restart spreads reconnects out instead of every client
|
||||||
|
* retrying on the same tick.
|
||||||
|
*/
|
||||||
|
export function getReconnectDelayMs(
|
||||||
|
attempt: number,
|
||||||
|
options: ReconnectDelayOptions,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): number {
|
||||||
|
const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs);
|
||||||
|
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on
|
||||||
|
// every composer that opens the latest preview deploy for each branch. The
|
||||||
|
// deploys are fixed sample data; the UI is a real plugin surface.
|
||||||
|
import type {
|
||||||
|
PluginButtonContentProps,
|
||||||
|
PluginButtonRegistration,
|
||||||
|
PluginClientContext,
|
||||||
|
} from "@getpaseo/plugin/client";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
type DeployStatus = "ready" | "building" | "failed";
|
||||||
|
|
||||||
|
interface Deploy {
|
||||||
|
branch: string;
|
||||||
|
status: DeployStatus;
|
||||||
|
detail: string;
|
||||||
|
age: string;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPLOYS: Deploy[] = [
|
||||||
|
{ branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" },
|
||||||
|
{
|
||||||
|
branch: "perf/provider-snapshot-cache",
|
||||||
|
status: "building",
|
||||||
|
detail: "Bundling app",
|
||||||
|
age: "now",
|
||||||
|
progress: 0.64,
|
||||||
|
},
|
||||||
|
{ branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" },
|
||||||
|
{
|
||||||
|
branch: "i18n/ko-settings",
|
||||||
|
status: "failed",
|
||||||
|
detail: "Missing key settings.notifications",
|
||||||
|
age: "26m",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function DeploysSheet({ theme, layout }: PluginButtonContentProps) {
|
||||||
|
const mono = layout.platform === "ios" ? "Menlo" : "monospace";
|
||||||
|
const styles = useMemo(
|
||||||
|
() => ({
|
||||||
|
// Popovers on tablets and desktop are narrow by default; give the
|
||||||
|
// branch names room. Phones show the sheet at full width.
|
||||||
|
body: { gap: 14, width: layout.compact ? undefined : 400 },
|
||||||
|
production: {
|
||||||
|
padding: 14,
|
||||||
|
gap: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
||||||
|
label: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||||
|
version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const },
|
||||||
|
live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const },
|
||||||
|
heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 },
|
||||||
|
list: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
divider: { height: 1, backgroundColor: theme.colors.border },
|
||||||
|
text: { flex: 1, gap: 4 },
|
||||||
|
branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono },
|
||||||
|
detail: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||||
|
failed: { color: theme.colors.statusDanger, fontSize: 13 },
|
||||||
|
age: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||||
|
track: {
|
||||||
|
height: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
overflow: "hidden" as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[theme, mono, layout.compact],
|
||||||
|
);
|
||||||
|
const dotColor: Record<DeployStatus, string> = {
|
||||||
|
ready: theme.colors.statusSuccess,
|
||||||
|
building: theme.colors.statusWarning,
|
||||||
|
failed: theme.colors.statusDanger,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.production}>
|
||||||
|
<View style={styles.productionTop}>
|
||||||
|
<Dot color={theme.colors.statusSuccess} />
|
||||||
|
<Text style={styles.live}>Live</Text>
|
||||||
|
<Text style={styles.label}>Production · main</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.version}>v0.14.0-beta.1</Text>
|
||||||
|
<Text style={styles.label}>Deployed 3h ago</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.heading}>Previews</Text>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{DEPLOYS.map((deploy, index) => (
|
||||||
|
<View key={deploy.branch}>
|
||||||
|
{index > 0 ? <View style={styles.divider} /> : null}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Dot color={dotColor[deploy.status]} />
|
||||||
|
<View style={styles.text}>
|
||||||
|
<Text style={styles.branch} numberOfLines={1}>
|
||||||
|
{deploy.branch}
|
||||||
|
</Text>
|
||||||
|
<Text style={deploy.status === "failed" ? styles.failed : styles.detail}>
|
||||||
|
{deploy.detail}
|
||||||
|
</Text>
|
||||||
|
{deploy.progress !== undefined ? (
|
||||||
|
<Progress
|
||||||
|
value={deploy.progress}
|
||||||
|
track={styles.track}
|
||||||
|
color={theme.colors.statusWarning}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.age}>{deploy.age}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Progress({ value, track, color }: { value: number; track: object; color: string }) {
|
||||||
|
const fill = useMemo(
|
||||||
|
() => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }),
|
||||||
|
[value, color],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style={track}>
|
||||||
|
<View style={fill} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color }: { color: string }) {
|
||||||
|
const style = useMemo(
|
||||||
|
() => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }),
|
||||||
|
[color],
|
||||||
|
);
|
||||||
|
return <View style={style} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function contribute(client: PluginClientContext) {
|
||||||
|
const pills = new Map<string, PluginButtonRegistration>();
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||||
|
if (lifetime.signal.aborted || !agent.workspaceId) return;
|
||||||
|
pills.get(agent.id)?.remove();
|
||||||
|
pills.set(
|
||||||
|
agent.id,
|
||||||
|
client.addComposerPill({
|
||||||
|
id: "deploys",
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
|
agentId: agent.id,
|
||||||
|
button: {
|
||||||
|
title: "Preview deploys",
|
||||||
|
icon: "Rocket",
|
||||||
|
label: "Deploys",
|
||||||
|
behavior: { kind: "popover", Content: DeploysSheet },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
void client.paseo.agents
|
||||||
|
.list({ subscribe: {}, signal: lifetime.signal })
|
||||||
|
.then(({ subscription }) => {
|
||||||
|
subscription.subscribe({
|
||||||
|
snapshot: ({ entries }) => {
|
||||||
|
for (const pill of pills.values()) pill.remove();
|
||||||
|
pills.clear();
|
||||||
|
for (const { agent } of entries) register(agent);
|
||||||
|
},
|
||||||
|
update: (message) => {
|
||||||
|
if (message.type !== "agent_update") return;
|
||||||
|
const update = message.payload;
|
||||||
|
if (update.kind === "remove") {
|
||||||
|
pills.get(update.agentId)?.remove();
|
||||||
|
pills.delete(update.agentId);
|
||||||
|
} else register(update.agent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
lifetime.abort();
|
||||||
|
for (const pill of pills.values()) pill.remove();
|
||||||
|
pills.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"id": "preview-deploys",
|
||||||
|
"requirements": { "paseo": ">=0.8.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// Scripted Claude Code and Codex clients for staging store screenshots.
|
||||||
|
// Each agent replays a fixed conversation through the real daemon, so the app
|
||||||
|
// renders real timeline rows, permission prompts, and running states.
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
AgentCapabilityFlags,
|
||||||
|
AgentClient,
|
||||||
|
AgentMode,
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentPermissionRequest,
|
||||||
|
AgentPermissionResponse,
|
||||||
|
AgentPersistenceHandle,
|
||||||
|
AgentPromptInput,
|
||||||
|
AgentRunResult,
|
||||||
|
AgentSession,
|
||||||
|
AgentSessionConfig,
|
||||||
|
AgentStreamEvent,
|
||||||
|
AgentTimelineItem,
|
||||||
|
ToolCallDetail,
|
||||||
|
} from "../../../packages/server/src/server/agent/agent-sdk-types.js";
|
||||||
|
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
|
|
||||||
|
export type ScriptStep =
|
||||||
|
| { kind: "say"; text: string }
|
||||||
|
| { kind: "think"; text: string }
|
||||||
|
| { kind: "tool"; name: string; detail: ToolCallDetail }
|
||||||
|
| { kind: "todo"; items: { text: string; completed: boolean }[] }
|
||||||
|
| { kind: "wait"; ms: number }
|
||||||
|
| { kind: "running-tool"; name: string; detail: ToolCallDetail }
|
||||||
|
| { kind: "ask"; name: string; title: string; detail: ToolCallDetail };
|
||||||
|
|
||||||
|
export interface AgentScript {
|
||||||
|
/** Matched against the start of the prompt that opens the turn. */
|
||||||
|
prompt: string;
|
||||||
|
steps: ScriptStep[];
|
||||||
|
/** Leave the turn open after the last step. */
|
||||||
|
keepRunning?: boolean;
|
||||||
|
/** Pause between steps, so "Worked for" reflects a believable duration. */
|
||||||
|
stepDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS: Record<string, AgentModelDefinition[]> = {
|
||||||
|
claude: [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-opus-5-5",
|
||||||
|
label: "Opus 5.5",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium", isDefault: true },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "medium",
|
||||||
|
},
|
||||||
|
{ provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" },
|
||||||
|
],
|
||||||
|
codex: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.6-sol",
|
||||||
|
label: "GPT-5.6-Sol",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium", isDefault: true },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function promptText(prompt: AgentPromptInput): string {
|
||||||
|
if (typeof prompt === "string") return prompt;
|
||||||
|
return prompt
|
||||||
|
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScriptedSession implements AgentSession {
|
||||||
|
readonly capabilities = CAPABILITIES;
|
||||||
|
readonly id = randomUUID();
|
||||||
|
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
|
||||||
|
private readonly history: AgentStreamEvent[] = [];
|
||||||
|
private pending: AgentPermissionRequest[] = [];
|
||||||
|
private activeTurnId: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly provider: string,
|
||||||
|
private readonly config: AgentSessionConfig,
|
||||||
|
private readonly scripts: AgentScript[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private emit(event: AgentStreamEvent): void {
|
||||||
|
const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event;
|
||||||
|
this.history.push(tagged);
|
||||||
|
for (const listener of this.listeners) listener(tagged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeline(item: AgentTimelineItem): void {
|
||||||
|
this.emit({ type: "timeline", provider: this.provider, item });
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<AgentRunResult> {
|
||||||
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||||
|
const turnId = randomUUID();
|
||||||
|
this.activeTurnId = turnId;
|
||||||
|
const text = promptText(prompt);
|
||||||
|
const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt));
|
||||||
|
setTimeout(() => void this.play(text, script), 50);
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async play(text: string, script: AgentScript | undefined): Promise<void> {
|
||||||
|
this.emit({ type: "turn_started", provider: this.provider });
|
||||||
|
this.timeline({ type: "user_message", text, messageId: randomUUID() });
|
||||||
|
const delay = script?.stepDelayMs ?? 400;
|
||||||
|
for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay));
|
||||||
|
this.playStep(step);
|
||||||
|
if (step.kind === "ask") return;
|
||||||
|
}
|
||||||
|
if (script?.keepRunning) return;
|
||||||
|
this.emit({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: this.provider,
|
||||||
|
usage: { inputTokens: 48_210, outputTokens: 3_904 },
|
||||||
|
});
|
||||||
|
this.activeTurnId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private playStep(step: ScriptStep): void {
|
||||||
|
switch (step.kind) {
|
||||||
|
case "say":
|
||||||
|
this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() });
|
||||||
|
return;
|
||||||
|
case "think":
|
||||||
|
this.timeline({ type: "reasoning", text: step.text });
|
||||||
|
return;
|
||||||
|
case "todo":
|
||||||
|
this.timeline({ type: "todo", items: step.items });
|
||||||
|
return;
|
||||||
|
case "wait":
|
||||||
|
return;
|
||||||
|
case "tool":
|
||||||
|
case "running-tool": {
|
||||||
|
const callId = randomUUID();
|
||||||
|
this.timeline({
|
||||||
|
type: "tool_call",
|
||||||
|
callId,
|
||||||
|
name: step.name,
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
detail: step.detail,
|
||||||
|
});
|
||||||
|
if (step.kind === "tool") {
|
||||||
|
this.timeline({
|
||||||
|
type: "tool_call",
|
||||||
|
callId,
|
||||||
|
name: step.name,
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
detail: step.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ask": {
|
||||||
|
const request: AgentPermissionRequest = {
|
||||||
|
id: randomUUID(),
|
||||||
|
provider: this.provider,
|
||||||
|
name: step.name,
|
||||||
|
kind: "tool",
|
||||||
|
title: step.title,
|
||||||
|
detail: step.detail,
|
||||||
|
};
|
||||||
|
this.pending = [request];
|
||||||
|
this.emit({ type: "permission_requested", provider: this.provider, request });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
|
this.listeners.add(callback);
|
||||||
|
return () => this.listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
yield* this.history;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.id,
|
||||||
|
model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null,
|
||||||
|
thinkingOptionId: this.config.thinkingOptionId ?? "medium",
|
||||||
|
modeId: this.config.modeId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModes(): Promise<AgentMode[]> {
|
||||||
|
return getAgentProviderDefinition(this.provider).modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentMode(): Promise<string | null> {
|
||||||
|
return this.config.modeId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMode(): Promise<void> {}
|
||||||
|
|
||||||
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
|
return this.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondToPermission(requestId: string, resolution: AgentPermissionResponse) {
|
||||||
|
this.pending = [];
|
||||||
|
this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution });
|
||||||
|
}
|
||||||
|
|
||||||
|
describePersistence(): AgentPersistenceHandle {
|
||||||
|
return { provider: this.provider, sessionId: this.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
if (!this.activeTurnId) return;
|
||||||
|
this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" });
|
||||||
|
this.activeTurnId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScriptedAgentClient implements AgentClient {
|
||||||
|
readonly capabilities = CAPABILITIES;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly provider: string,
|
||||||
|
private readonly scripts: AgentScript[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
return new ScriptedSession(this.provider, config, this.scripts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
_handle: AgentPersistenceHandle,
|
||||||
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
): Promise<AgentSession> {
|
||||||
|
return new ScriptedSession(
|
||||||
|
this.provider,
|
||||||
|
{ provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides },
|
||||||
|
this.scripts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCatalog() {
|
||||||
|
return {
|
||||||
|
models: MODELS[this.provider] ?? [],
|
||||||
|
modes: getAgentProviderDefinition(this.provider).modes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
// Stages the demo host used for store screenshots.
|
||||||
|
//
|
||||||
|
// npx tsx assets/store-screenshots/demo/stage.mts
|
||||||
|
//
|
||||||
|
// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude
|
||||||
|
// Code and Codex clients, adds a local clone of this repository as the `paseo`
|
||||||
|
// project, cuts real worktrees, applies a real change to the hero worktree, and
|
||||||
|
// opens a terminal there. Leave it running while you capture.
|
||||||
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js";
|
||||||
|
import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(here, "../../..");
|
||||||
|
const demoRoot = path.join(repoRoot, ".dev/store-capture/demo");
|
||||||
|
const codeRoot = path.join(demoRoot, "code");
|
||||||
|
const pluginDir = path.join(here, "preview-deploys");
|
||||||
|
const port = Number(process.env.DEMO_PORT ?? 7791);
|
||||||
|
const minute = 60_000;
|
||||||
|
|
||||||
|
const claudeScripts: AgentScript[] = [];
|
||||||
|
const codexScripts: AgentScript[] = [];
|
||||||
|
|
||||||
|
function git(cwd: string, ...args: string[]): string {
|
||||||
|
return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cwd: string, command: string): string {
|
||||||
|
return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareRepos(): Promise<void> {
|
||||||
|
// A fresh clone each run: worktrees from an earlier run stay registered in it.
|
||||||
|
// Worktree setup (npm ci, builds) is dropped so new worktrees open instantly.
|
||||||
|
const paseo = path.join(codeRoot, "paseo");
|
||||||
|
await rm(paseo, { recursive: true, force: true });
|
||||||
|
await mkdir(codeRoot, { recursive: true });
|
||||||
|
execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]);
|
||||||
|
git(paseo, "checkout", "-q", "-B", "main");
|
||||||
|
git(paseo, "remote", "remove", "origin");
|
||||||
|
const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8"));
|
||||||
|
delete config.worktree;
|
||||||
|
await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||||
|
git(paseo, "commit", "-qam", "chore: skip worktree setup in demo");
|
||||||
|
const dotfiles = path.join(codeRoot, "dotfiles");
|
||||||
|
if (!existsSync(dotfiles)) {
|
||||||
|
await mkdir(dotfiles, { recursive: true });
|
||||||
|
await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n");
|
||||||
|
await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n");
|
||||||
|
git(dotfiles, "init", "-q", "-b", "main");
|
||||||
|
git(dotfiles, "add", ".");
|
||||||
|
git(dotfiles, "commit", "-qm", "Initial dotfiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await prepareRepos();
|
||||||
|
const home = path.join(demoRoot, "home");
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
await mkdir(home, { recursive: true });
|
||||||
|
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
agentClients: {
|
||||||
|
claude: new ScriptedAgentClient("claude", claudeScripts),
|
||||||
|
codex: new ScriptedAgentClient("codex", codexScripts),
|
||||||
|
},
|
||||||
|
listen: "127.0.0.1",
|
||||||
|
listenPort: port,
|
||||||
|
paseoHomeRoot: home,
|
||||||
|
mcpEnabled: false,
|
||||||
|
cleanup: false,
|
||||||
|
pluginsEnabled: true,
|
||||||
|
plugins: {
|
||||||
|
"preview-deploys": { source: "directory", path: pluginDir, enabled: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
|
appVersion: "0.14.0",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: {} });
|
||||||
|
|
||||||
|
const paseo = await client.addProject(path.join(codeRoot, "paseo"));
|
||||||
|
const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles"));
|
||||||
|
const paseoProjectId = paseo.project!.projectId;
|
||||||
|
|
||||||
|
async function worktree(title: string, branchName: string) {
|
||||||
|
const result = await client.createWorkspace({
|
||||||
|
title,
|
||||||
|
source: {
|
||||||
|
kind: "worktree",
|
||||||
|
projectId: paseoProjectId,
|
||||||
|
action: "branch-off",
|
||||||
|
baseBranch: "main",
|
||||||
|
branchName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`);
|
||||||
|
return result.workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agent(input: {
|
||||||
|
provider: "claude" | "codex";
|
||||||
|
workspace: { id: string; workspaceDirectory?: string | null };
|
||||||
|
cwd: string;
|
||||||
|
title: string;
|
||||||
|
script: AgentScript;
|
||||||
|
}) {
|
||||||
|
(input.provider === "claude" ? claudeScripts : codexScripts).push(input.script);
|
||||||
|
return client.createAgent({
|
||||||
|
provider: input.provider,
|
||||||
|
cwd: input.cwd,
|
||||||
|
workspaceId: input.workspace.id,
|
||||||
|
title: input.title,
|
||||||
|
initialPrompt: input.script.prompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Done: finished earlier and already read.
|
||||||
|
const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1");
|
||||||
|
const releaseAgent = await agent({
|
||||||
|
provider: "codex",
|
||||||
|
workspace: release,
|
||||||
|
cwd: release.workspaceDirectory!,
|
||||||
|
title: "Release v0.14.0-beta.1",
|
||||||
|
script: {
|
||||||
|
prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "shell",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "npm run release:beta",
|
||||||
|
output: "Tagged v0.14.0-beta.1\n",
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dotfilesWorkspace = (
|
||||||
|
await client.createWorkspace({
|
||||||
|
title: "Add a tmux status line",
|
||||||
|
source: {
|
||||||
|
kind: "directory",
|
||||||
|
path: path.join(codeRoot, "dotfiles"),
|
||||||
|
projectId: dotfiles.project!.projectId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).workspace!;
|
||||||
|
const tmuxAgent = await agent({
|
||||||
|
provider: "claude",
|
||||||
|
workspace: dotfilesWorkspace,
|
||||||
|
cwd: path.join(codeRoot, "dotfiles"),
|
||||||
|
title: "Add a tmux status line",
|
||||||
|
script: {
|
||||||
|
prompt: "Add a minimal tmux status line with the session name and battery.",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Edit",
|
||||||
|
detail: {
|
||||||
|
type: "edit",
|
||||||
|
filePath: "tmux.conf",
|
||||||
|
oldString: "",
|
||||||
|
newString: "set -g status-right '#S · #{battery_percentage}'",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "Added a status line with the session name on the left and battery on the right.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pause(3000);
|
||||||
|
await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]);
|
||||||
|
|
||||||
|
// Working: turns that stay open while you capture.
|
||||||
|
const ipad = await worktree(
|
||||||
|
"Keep the composer above the iPad keyboard",
|
||||||
|
"fix/ipad-keyboard-inset",
|
||||||
|
);
|
||||||
|
await agent({
|
||||||
|
provider: "codex",
|
||||||
|
workspace: ipad,
|
||||||
|
cwd: ipad.workspaceDirectory!,
|
||||||
|
title: "Keep the composer above the iPad keyboard",
|
||||||
|
script: {
|
||||||
|
prompt:
|
||||||
|
"On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.",
|
||||||
|
keepRunning: true,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "shell",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: 'rg -n "keyboardHeight" packages/app/src',
|
||||||
|
output:
|
||||||
|
"packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n",
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "read",
|
||||||
|
detail: {
|
||||||
|
type: "read",
|
||||||
|
filePath: "packages/app/src/keyboard/shift/internal/provider.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "running-tool",
|
||||||
|
name: "shell",
|
||||||
|
detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshots = await worktree(
|
||||||
|
"Cache provider snapshots on disk",
|
||||||
|
"perf/provider-snapshot-cache",
|
||||||
|
);
|
||||||
|
await agent({
|
||||||
|
provider: "claude",
|
||||||
|
workspace: snapshots,
|
||||||
|
cwd: snapshots.workspaceDirectory!,
|
||||||
|
title: "Cache provider snapshots on disk",
|
||||||
|
script: {
|
||||||
|
prompt:
|
||||||
|
"Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.",
|
||||||
|
keepRunning: true,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "todo",
|
||||||
|
items: [
|
||||||
|
{ text: "Find where snapshots are built", completed: true },
|
||||||
|
{ text: "Persist the last snapshot per host", completed: false },
|
||||||
|
{ text: "Refresh in the background after boot", completed: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Read",
|
||||||
|
detail: {
|
||||||
|
type: "read",
|
||||||
|
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "running-tool",
|
||||||
|
name: "Edit",
|
||||||
|
detail: {
|
||||||
|
type: "edit",
|
||||||
|
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Needs input: waiting on a permission.
|
||||||
|
const korean = await worktree("Korean translations for Settings", "i18n/ko-settings");
|
||||||
|
await agent({
|
||||||
|
provider: "claude",
|
||||||
|
workspace: korean,
|
||||||
|
cwd: korean.workspaceDirectory!,
|
||||||
|
title: "Korean translations for Settings",
|
||||||
|
script: {
|
||||||
|
prompt: "Translate the new Settings strings into Korean and open a PR.",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Read",
|
||||||
|
detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Edit",
|
||||||
|
detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Bash",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "npm run test --workspace=@getpaseo/app -- i18n",
|
||||||
|
output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n",
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." },
|
||||||
|
{
|
||||||
|
kind: "ask",
|
||||||
|
name: "Bash",
|
||||||
|
title: "Bash",
|
||||||
|
detail: { type: "shell", command: "git push -u origin i18n/ko-settings" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ready to review: a second finished agent.
|
||||||
|
const terminals = await worktree(
|
||||||
|
"Remember the last terminal per workspace",
|
||||||
|
"feat/remember-terminal",
|
||||||
|
);
|
||||||
|
await agent({
|
||||||
|
provider: "codex",
|
||||||
|
workspace: terminals,
|
||||||
|
cwd: terminals.workspaceDirectory!,
|
||||||
|
title: "Remember the last terminal per workspace",
|
||||||
|
script: {
|
||||||
|
prompt:
|
||||||
|
"When I switch back to a workspace, open the terminal I was using last instead of the first one.",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "shell",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: 'rg -n "activeTerminalId" packages/app/src',
|
||||||
|
output: "",
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugin: the agent "built" the preview-deploys plugin this daemon runs.
|
||||||
|
const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys");
|
||||||
|
const pluginSource = (file: string) => run(pluginDir, `cat ${file}`);
|
||||||
|
await agent({
|
||||||
|
provider: "claude",
|
||||||
|
workspace: plugin,
|
||||||
|
cwd: plugin.workspaceDirectory!,
|
||||||
|
title: "Build a preview deploys plugin",
|
||||||
|
script: {
|
||||||
|
prompt: "Build me a plugin that shows our preview deploys right next to the composer.",
|
||||||
|
stepDelayMs: 6_000,
|
||||||
|
steps: [
|
||||||
|
{ kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } },
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Write",
|
||||||
|
detail: {
|
||||||
|
type: "write",
|
||||||
|
filePath: "plugins/preview-deploys/paseo-plugin.json",
|
||||||
|
content: pluginSource("paseo-plugin.json"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Write",
|
||||||
|
detail: {
|
||||||
|
type: "write",
|
||||||
|
filePath: "plugins/preview-deploys/index.client.tsx",
|
||||||
|
content: pluginSource("index.client.tsx"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Bash",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "paseo plugin install ./plugins/preview-deploys",
|
||||||
|
output: "Installed preview-deploys\n",
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hero: Claude finished a real change in a real worktree.
|
||||||
|
const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter");
|
||||||
|
const cwd = jitter.workspaceDirectory!;
|
||||||
|
await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules"));
|
||||||
|
execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]);
|
||||||
|
const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts");
|
||||||
|
const vitest = run(
|
||||||
|
cwd,
|
||||||
|
"npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||||
|
).replace(/^ RUN .*\n/m, "");
|
||||||
|
const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts");
|
||||||
|
await agent({
|
||||||
|
provider: "claude",
|
||||||
|
workspace: jitter,
|
||||||
|
cwd,
|
||||||
|
title: "Add jitter to reconnect backoff",
|
||||||
|
script: {
|
||||||
|
prompt:
|
||||||
|
'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.',
|
||||||
|
stepDelayMs: 7_000,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Grep",
|
||||||
|
detail: {
|
||||||
|
type: "search",
|
||||||
|
toolName: "grep",
|
||||||
|
query: "reconnectAttempt",
|
||||||
|
filePaths: ["packages/client/src/daemon-client.ts"],
|
||||||
|
numFiles: 1,
|
||||||
|
numMatches: 9,
|
||||||
|
mode: "files_with_matches",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Read",
|
||||||
|
detail: {
|
||||||
|
type: "read",
|
||||||
|
filePath: "packages/client/src/daemon-client.ts",
|
||||||
|
offset: 7040,
|
||||||
|
limit: 60,
|
||||||
|
content: readExcerpt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Write",
|
||||||
|
detail: {
|
||||||
|
type: "write",
|
||||||
|
filePath: "packages/client/src/connection/reconnect-delay.ts",
|
||||||
|
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Edit",
|
||||||
|
detail: {
|
||||||
|
type: "edit",
|
||||||
|
filePath: "packages/client/src/daemon-client.ts",
|
||||||
|
unifiedDiff: clientDiff,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Write",
|
||||||
|
detail: {
|
||||||
|
type: "write",
|
||||||
|
filePath: "packages/client/src/connection/reconnect-delay.test.ts",
|
||||||
|
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Bash",
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||||
|
output: vitest,
|
||||||
|
exitCode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
name: "Edit",
|
||||||
|
detail: {
|
||||||
|
type: "edit",
|
||||||
|
filePath: "CHANGELOG.md",
|
||||||
|
unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "say",
|
||||||
|
text: [
|
||||||
|
"Reconnects now back off with jitter.",
|
||||||
|
"",
|
||||||
|
"- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.",
|
||||||
|
'- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.',
|
||||||
|
"- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.",
|
||||||
|
"",
|
||||||
|
"Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminal = await client.createTerminal(cwd, "zsh", undefined, {
|
||||||
|
workspaceId: jitter.id,
|
||||||
|
command: "bash",
|
||||||
|
args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"],
|
||||||
|
});
|
||||||
|
const terminalId = terminal.terminal?.id;
|
||||||
|
if (terminalId) {
|
||||||
|
await pause(1500);
|
||||||
|
for (const line of [
|
||||||
|
"git status --short",
|
||||||
|
"cd packages/client",
|
||||||
|
"npx vitest run reconnect --reporter=tree",
|
||||||
|
]) {
|
||||||
|
client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` });
|
||||||
|
await pause(4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`);
|
||||||
|
await writeFile(
|
||||||
|
path.join(demoRoot, "state.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{ port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const stop = async () => {
|
||||||
|
await client.close();
|
||||||
|
await daemon.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", stop);
|
||||||
|
process.on("SIGINT", stop);
|
||||||
|
void minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a
|
||||||
|
# phone-width terminal, and vitest output without the absolute checkout path
|
||||||
|
# or the long timing breakdown. Vitest drops its colors when piped, so the
|
||||||
|
# wrapper puts its usual green checks and grey timings back.
|
||||||
|
export FORCE_COLOR=1
|
||||||
|
__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; }
|
||||||
|
__branch() { git branch --show-current 2>/dev/null; }
|
||||||
|
__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; }
|
||||||
|
PROMPT_COMMAND='printf "\e]0;zsh\a"'
|
||||||
|
PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] '
|
||||||
|
npx() {
|
||||||
|
command npx "$@" 2>&1 | sed -E \
|
||||||
|
-e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \
|
||||||
|
-e $'s/✓/\e[32m✓\e[0m/' \
|
||||||
|
-e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \
|
||||||
|
-e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \
|
||||||
|
-e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/'
|
||||||
|
}
|
||||||
|
clear
|
||||||
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 607 KiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 298 KiB |
@@ -0,0 +1,396 @@
|
|||||||
|
// Renders the App Store and Google Play screenshots from the native captures in
|
||||||
|
// raw/. Every image is resized with Lanczos to the exact pixel size it occupies
|
||||||
|
// on the canvas, and the page renders at device scale 1, so the browser never
|
||||||
|
// resamples a capture.
|
||||||
|
//
|
||||||
|
// node assets/store-screenshots/render.mjs
|
||||||
|
//
|
||||||
|
// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts;
|
||||||
|
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repo = path.resolve(root, "../..");
|
||||||
|
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||||
|
const output = path.join(root, "output");
|
||||||
|
|
||||||
|
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||||
|
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||||
|
const DEVICES = {
|
||||||
|
iphone: {
|
||||||
|
capture: { width: 1320, height: 2868 },
|
||||||
|
radius: 186,
|
||||||
|
bezel: 22,
|
||||||
|
island: { width: 378, height: 112, top: 33 },
|
||||||
|
},
|
||||||
|
ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every title wraps to two lines and every subline fits on one; the renderer
|
||||||
|
// fails otherwise, because the device is placed from that fixed text height.
|
||||||
|
//
|
||||||
|
// Each scene names its capture per device, and a device entry may override the
|
||||||
|
// title or subline where the copy depends on the form factor.
|
||||||
|
//
|
||||||
|
// A callout lifts a region of a capture out of the device. `crop` is in native
|
||||||
|
// capture pixels, `zoom` is its size relative to the device screen (capped to
|
||||||
|
// the canvas width), and the callout is centred on the spot the crop comes
|
||||||
|
// from, moved by `shift` native pixels down the screen.
|
||||||
|
const SCENES = [
|
||||||
|
{
|
||||||
|
id: "sidebar",
|
||||||
|
backdrop: "agents",
|
||||||
|
title: "Run many coding agents at once",
|
||||||
|
body: "See which agents are working and which need you.",
|
||||||
|
iphone: { capture: "sidebar" },
|
||||||
|
ipad: { capture: "sidebar" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat",
|
||||||
|
title: "Your full dev environment on your phone",
|
||||||
|
body: "Start agents and follow every edit and command.",
|
||||||
|
iphone: { capture: "chat" },
|
||||||
|
ipad: { capture: "chat", title: "Your full dev environment on your tablet" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plugins",
|
||||||
|
title: "Ask an agent to change the app itself",
|
||||||
|
body: "This Deploys sheet is a plugin an agent wrote.",
|
||||||
|
iphone: {
|
||||||
|
capture: "plugin",
|
||||||
|
callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 },
|
||||||
|
},
|
||||||
|
ipad: {
|
||||||
|
capture: "plugin",
|
||||||
|
body: "This Deploys panel is a plugin an agent wrote.",
|
||||||
|
callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
title: "Review the diff before you merge",
|
||||||
|
body: "Every file the agent changed, on its own branch.",
|
||||||
|
iphone: {
|
||||||
|
capture: "diff",
|
||||||
|
callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 },
|
||||||
|
},
|
||||||
|
ipad: { capture: "diff" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminal",
|
||||||
|
title: "A real terminal in every workspace",
|
||||||
|
body: "Run the tests yourself, on your own machine.",
|
||||||
|
iphone: {
|
||||||
|
capture: "terminal",
|
||||||
|
callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 },
|
||||||
|
},
|
||||||
|
ipad: {
|
||||||
|
capture: "terminal",
|
||||||
|
callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// `textTop` is where the text starts when the device is sized by the canvas
|
||||||
|
// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device
|
||||||
|
// by its width and centres the text and device instead.
|
||||||
|
const STORES = {
|
||||||
|
apple: {
|
||||||
|
device: "iphone",
|
||||||
|
width: 1320,
|
||||||
|
height: 2868,
|
||||||
|
textTop: 230,
|
||||||
|
titleSize: 100,
|
||||||
|
bodySize: 46,
|
||||||
|
textWidth: 1120,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
device: "iphone",
|
||||||
|
width: 1440,
|
||||||
|
height: 2560,
|
||||||
|
textTop: 150,
|
||||||
|
titleSize: 96,
|
||||||
|
bodySize: 44,
|
||||||
|
textWidth: 1200,
|
||||||
|
},
|
||||||
|
"apple-ipad": {
|
||||||
|
device: "ipad",
|
||||||
|
width: 2064,
|
||||||
|
height: 2752,
|
||||||
|
textTop: 200,
|
||||||
|
titleSize: 124,
|
||||||
|
bodySize: 56,
|
||||||
|
textWidth: 1400,
|
||||||
|
},
|
||||||
|
"google-tablet": {
|
||||||
|
device: "ipad",
|
||||||
|
width: 1800,
|
||||||
|
height: 3200,
|
||||||
|
textTop: 200,
|
||||||
|
titleSize: 136,
|
||||||
|
bodySize: 60,
|
||||||
|
textWidth: 1560,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TITLE_LINE = 1.06;
|
||||||
|
const BODY_LINE = 1.3;
|
||||||
|
const BODY_GAP = 30;
|
||||||
|
// The device starts this far below the text and runs BLEED off the bottom.
|
||||||
|
const GAP = 140;
|
||||||
|
const BLEED = 40;
|
||||||
|
const MARGIN = 56;
|
||||||
|
const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" };
|
||||||
|
|
||||||
|
function findFont() {
|
||||||
|
const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean);
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const file = path.join(dir, "SFNS.ttf");
|
||||||
|
if (existsSync(file)) return file;
|
||||||
|
}
|
||||||
|
throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The device fills the canvas from GAP below the text to just past the bottom
|
||||||
|
// edge, so a taller canvas gets a larger device rather than a larger gap. When
|
||||||
|
// that would be wider than the canvas, the device takes the full width and the
|
||||||
|
// text and the whole device sit centred, still GAP apart.
|
||||||
|
function deviceLayout(store, device) {
|
||||||
|
const { capture, bezel } = device;
|
||||||
|
const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE;
|
||||||
|
const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel;
|
||||||
|
const widest = store.width - 2 * MARGIN - 2 * bezel;
|
||||||
|
const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest);
|
||||||
|
const screenHeight = Math.round((screenWidth * capture.height) / capture.width);
|
||||||
|
const scale = screenWidth / capture.width;
|
||||||
|
const width = screenWidth + 2 * bezel;
|
||||||
|
const height = screenHeight + 2 * bezel;
|
||||||
|
const textTop =
|
||||||
|
screenWidth < widest
|
||||||
|
? store.height + BLEED - height - GAP - textHeight
|
||||||
|
: (store.height - height - GAP - textHeight) / 2;
|
||||||
|
const island = device.island && {
|
||||||
|
width: Math.round(device.island.width * scale),
|
||||||
|
height: Math.round(device.island.height * scale),
|
||||||
|
top: Math.round(device.island.top * scale),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
textTop: Math.round(textTop),
|
||||||
|
left: Math.round((store.width - width) / 2),
|
||||||
|
top: Math.round(textTop + textHeight + GAP),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bezel,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
scale,
|
||||||
|
radius: Math.round(device.radius * scale),
|
||||||
|
island,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calloutLayout(callout, frame, store) {
|
||||||
|
const [cropX, cropY, cropWidth, cropHeight] = callout.crop;
|
||||||
|
const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth);
|
||||||
|
const width = Math.round(cropWidth * scale);
|
||||||
|
const height = Math.round(cropHeight * scale);
|
||||||
|
const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale;
|
||||||
|
const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale;
|
||||||
|
const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN));
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left: clamp(centreX - width / 2, store.width - width),
|
||||||
|
top: clamp(centreY - height / 2, store.height - height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resize(source, crop, width, height, file) {
|
||||||
|
let image = sharp(source);
|
||||||
|
if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] });
|
||||||
|
await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file);
|
||||||
|
return pathToFileURL(file).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent marks scattered behind the first panel, from the homepage's featured
|
||||||
|
// agents. Each entry is [mark, centre x, centre y, size, rotation], with the
|
||||||
|
// centre as a fraction of the canvas and the size as a fraction of its width.
|
||||||
|
const AGENT_MARKS = [
|
||||||
|
["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14],
|
||||||
|
["CodexIcon", 0.89, 0.1, 0.13, 12],
|
||||||
|
["PiIcon", 0.07, 0.34, 0.1, 10],
|
||||||
|
["OmpIcon", 0.93, 0.37, 0.11, -10],
|
||||||
|
["OpenCodeIcon", 0.08, 0.6, 0.11, -8],
|
||||||
|
["CursorIcon", 0.92, 0.63, 0.1, 16],
|
||||||
|
["MuseCodeIcon", 0.07, 0.85, 0.11, 14],
|
||||||
|
["AntigravityIcon", 0.92, 0.88, 0.12, -12],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadAgentMarks(work) {
|
||||||
|
const bundle = path.join(work, "agent-icons.mjs");
|
||||||
|
await build({
|
||||||
|
entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")],
|
||||||
|
outfile: bundle,
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
jsx: "automatic",
|
||||||
|
});
|
||||||
|
const icons = await import(pathToFileURL(bundle).href);
|
||||||
|
return Object.fromEntries(
|
||||||
|
AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backdrop(store, marks) {
|
||||||
|
return AGENT_MARKS.map(([name, x, y, size, rotation]) => {
|
||||||
|
const side = Math.round(size * store.width);
|
||||||
|
const left = Math.round(x * store.width - side / 2);
|
||||||
|
const top = Math.round(y * store.height - side / 2);
|
||||||
|
return `<div class="mark" style="left:${left}px;top:${top}px;width:${side}px;height:${side}px;transform:rotate(${rotation}deg)">${marks[name]}</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) {
|
||||||
|
const behind = shot.backdrop === "agents" ? backdrop(store, marks) : "";
|
||||||
|
const { island } = frame;
|
||||||
|
const notch = island
|
||||||
|
? `<div class="island" style="top:${island.top}px;width:${island.width}px;height:${island.height}px;margin-left:-${island.width / 2}px;border-radius:${island.height / 2}px"></div>`
|
||||||
|
: "";
|
||||||
|
const lifted = callout
|
||||||
|
? `<img class="callout" src="${calloutImage}" style="left:${callout.left}px;top:${callout.top}px;width:${callout.width}px;height:${callout.height}px">`
|
||||||
|
: "";
|
||||||
|
return `<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: "SF Pro"; src: url("${pathToFileURL(font).href}"); font-weight: 1 1000; }
|
||||||
|
* { margin: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: ${store.width}px; height: ${store.height}px; overflow: hidden; background: ${COLORS.canvas}; }
|
||||||
|
body { position: relative; font-family: "SF Pro"; -webkit-font-smoothing: antialiased; }
|
||||||
|
.text { position: absolute; top: ${frame.textTop}px; left: 0; right: 0; margin: 0 auto; width: ${store.textWidth}px; text-align: center; }
|
||||||
|
.mark { position: absolute; color: #fff; opacity: 0.09; filter: grayscale(1) brightness(0) invert(1); }
|
||||||
|
.mark svg { display: block; width: 100%; height: 100%; }
|
||||||
|
h1 { color: ${COLORS.title}; font-size: ${store.titleSize}px; font-weight: 600; line-height: ${TITLE_LINE}; letter-spacing: -0.022em; text-wrap: balance; }
|
||||||
|
p { margin-top: ${BODY_GAP}px; color: ${COLORS.body}; font-size: ${store.bodySize}px; font-weight: 400; line-height: ${BODY_LINE}; letter-spacing: -0.005em; text-wrap: balance; }
|
||||||
|
.device { position: absolute; left: ${frame.left}px; top: ${frame.top}px; width: ${frame.width}px; height: ${frame.height}px; padding: ${frame.bezel}px; border-radius: ${frame.radius + frame.bezel}px; background: #040505; box-shadow: 0 0 0 3px #343b39, 0 0 0 5px #161b1a, 0 60px 140px rgba(0, 0, 0, 0.55); }
|
||||||
|
.screen { position: relative; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; border-radius: ${frame.radius}px; overflow: hidden; }
|
||||||
|
.screen img { display: block; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; }
|
||||||
|
.island { position: absolute; left: 50%; background: #000; }
|
||||||
|
.callout { position: absolute; display: block; border-radius: 36px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14), 0 40px 100px rgba(0, 0, 0, 0.6); }
|
||||||
|
</style>
|
||||||
|
${behind}
|
||||||
|
<div class="text"><h1>${shot.title}</h1><p>${shot.body}</p></div>
|
||||||
|
<div class="device"><div class="screen"><img src="${screen}">${notch}</div></div>
|
||||||
|
${lifted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderStore(browser, name, store, font, marks, work) {
|
||||||
|
const dir = path.join(output, name);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const view = await browser.newPage({
|
||||||
|
viewport: { width: store.width, height: store.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
|
const device = DEVICES[store.device];
|
||||||
|
const frame = deviceLayout(store, device);
|
||||||
|
const files = [];
|
||||||
|
for (const [index, scene] of SCENES.entries()) {
|
||||||
|
const stem = `${name}-${scene.id}`;
|
||||||
|
const shot = { ...scene, ...scene[store.device] };
|
||||||
|
const screen = await resize(
|
||||||
|
raw(store.device, shot.capture),
|
||||||
|
null,
|
||||||
|
frame.screenWidth,
|
||||||
|
frame.screenHeight,
|
||||||
|
path.join(work, `${stem}-screen.png`),
|
||||||
|
);
|
||||||
|
const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null;
|
||||||
|
const calloutImage = callout
|
||||||
|
? await resize(
|
||||||
|
raw(store.device, shot.callout.from),
|
||||||
|
shot.callout.crop,
|
||||||
|
callout.width,
|
||||||
|
callout.height,
|
||||||
|
path.join(work, `${stem}-callout.png`),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const html = path.join(work, `${stem}.html`);
|
||||||
|
await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks }));
|
||||||
|
await view.goto(pathToFileURL(html).href);
|
||||||
|
await view.evaluate(async () => {
|
||||||
|
await document.fonts.ready;
|
||||||
|
await Promise.all(Array.from(document.images, (image) => image.decode()));
|
||||||
|
});
|
||||||
|
await expectLines(view, "h1", 2, `${stem} title`);
|
||||||
|
await expectLines(view, "p", 1, `${stem} subline`);
|
||||||
|
const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`);
|
||||||
|
await sharp(await view.screenshot())
|
||||||
|
.removeAlpha()
|
||||||
|
.png()
|
||||||
|
.toFile(file);
|
||||||
|
files.push(file);
|
||||||
|
console.log(path.relative(repo, file));
|
||||||
|
}
|
||||||
|
await view.close();
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLines(view, selector, expected, label) {
|
||||||
|
const lines = await view.$eval(selector, (element) =>
|
||||||
|
Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)),
|
||||||
|
);
|
||||||
|
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPreview(rows) {
|
||||||
|
const tile = 440;
|
||||||
|
const gap = 40;
|
||||||
|
const resized = [];
|
||||||
|
let top = gap;
|
||||||
|
let width = 0;
|
||||||
|
for (const files of rows) {
|
||||||
|
let left = gap;
|
||||||
|
let rowHeight = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const meta = await sharp(file).metadata();
|
||||||
|
const height = Math.round((meta.height * tile) / meta.width);
|
||||||
|
const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer();
|
||||||
|
resized.push({ input, left, top });
|
||||||
|
left += tile + gap;
|
||||||
|
rowHeight = Math.max(rowHeight, height);
|
||||||
|
}
|
||||||
|
width = Math.max(width, left);
|
||||||
|
top += rowHeight + gap;
|
||||||
|
}
|
||||||
|
const file = path.join(output, "preview.png");
|
||||||
|
await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } })
|
||||||
|
.composite(resized)
|
||||||
|
.png()
|
||||||
|
.toFile(file);
|
||||||
|
console.log(path.relative(repo, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = findFont();
|
||||||
|
const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-"));
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const marks = await loadAgentMarks(work);
|
||||||
|
const rows = [];
|
||||||
|
for (const [name, store] of Object.entries(STORES)) {
|
||||||
|
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||||
|
}
|
||||||
|
await renderPreview(rows);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await rm(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed)
|
|||||||
|
|
||||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||||
|
|
||||||
|
## Daemon restart recovery
|
||||||
|
|
||||||
|
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
|
||||||
|
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
|
||||||
|
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
|
||||||
|
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
||||||
|
|
||||||
|
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||||
|
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
|
||||||
|
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
|
||||||
|
|
||||||
|
Startup chooses candidates before anything else reads or repairs persisted running state, then
|
||||||
|
opens connections and resumes every candidate in the background. A provider that is slow to reopen
|
||||||
|
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
|
||||||
|
closing runtimes.
|
||||||
|
|
||||||
|
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
|
||||||
|
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
|
||||||
|
loop or exactly-once turn guarantee.
|
||||||
|
|
||||||
## Runtime residency
|
## Runtime residency
|
||||||
|
|
||||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||||
@@ -139,8 +159,10 @@ caller, child, and host into one watch.
|
|||||||
|
|
||||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
|
||||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
recovery is observed only after its resume was sent, so the parent never hears that it was closed
|
||||||
|
in between. Archived or deleted parents suppress delivery;
|
||||||
|
parent-owned creation watches also stop
|
||||||
reporting after detachment.
|
reporting after detachment.
|
||||||
|
|
||||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
|
|||||||
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
|
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
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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`).
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ 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.
|
||||||
@@ -333,11 +337,10 @@ export default function contribute(client: PluginClientContext) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
|
The contribution function must return cleanup for resources created by plugin code. Server cleanup
|
||||||
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
|
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
|
||||||
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
|
not interrupt host teardown. Client installation retention and disposal follow the public
|
||||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
|
||||||
errors are logged and do not interrupt host teardown.
|
|
||||||
|
|
||||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||||
and render error boundary. The contributed component owns the complete body below the header.
|
and render error boundary. The contributed component owns the complete body below the header.
|
||||||
@@ -382,6 +385,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
|||||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||||
catalog is complete.
|
catalog is complete.
|
||||||
|
|
||||||
|
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||||
|
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||||
|
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||||
|
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||||
|
passing the bundle string still costs memory and time once per client lifetime.
|
||||||
|
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||||
|
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||||
|
in-memory bundles usable, and a later catalog retries persistence.
|
||||||
|
|
||||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||||
@@ -428,21 +440,22 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
|||||||
|
|
||||||
## Lifecycle hooks
|
## Lifecycle hooks
|
||||||
|
|
||||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
|
||||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
|
||||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
|
||||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
|
||||||
re-derived on each open and following the plugin's lifecycle. See
|
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||||
`plugin-examples/lifecycle-logger` registers all
|
|
||||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
|
||||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
|
||||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
|
||||||
executable definitions stay in the plugin process.
|
ownership checks prefer the marker-verified worktree root over the legacy
|
||||||
|
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. Observers must not be awaited inside agent mutations: a callback can send a prompt or
|
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||||
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||||
|
|
||||||
## Contribute a provider
|
## Contribute a provider
|
||||||
|
|
||||||
@@ -613,7 +626,10 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
|
|||||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
also run after reconnect; replace your view before applying its subsequent updates. An installation
|
||||||
|
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
|
||||||
|
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
|
||||||
|
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
|
||||||
owns every observation created through its API and releases them on unload, including setup failure.
|
owns every observation created through its API and releases them on unload, including setup failure.
|
||||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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.
|
||||||
@@ -10,6 +10,11 @@ then replaces the previous tool state or merges into the current text item. Inte
|
|||||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||||
and rebuilds the projection when an agent resumes.
|
and rebuilds the projection when an agent resumes.
|
||||||
|
|
||||||
|
Pagination bounds each read, not total resident history. Active and idle agents retain their
|
||||||
|
projected transcript; archive releases it and later history reads rebuild it from the provider.
|
||||||
|
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
|
||||||
|
must be released after their deltas have been consumed.
|
||||||
|
|
||||||
The invariants are:
|
The invariants are:
|
||||||
|
|
||||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ 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 ({
|
||||||
@@ -29,6 +33,57 @@ 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,3 +1,13 @@
|
|||||||
|
import {
|
||||||
|
withRetainedPlugin,
|
||||||
|
editRetainedDraft,
|
||||||
|
expectRetainedDraft,
|
||||||
|
expectStoredPluginTitle,
|
||||||
|
hideRetainedPreferences,
|
||||||
|
showRetainedPreferences,
|
||||||
|
expectChangedPlugin,
|
||||||
|
expectRemovedPlugin,
|
||||||
|
} from "../support/helpers/plugin-reconnect";
|
||||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
@@ -188,3 +198,51 @@ test("plugin content and host labels follow app language without losing an open
|
|||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
await verifyPluginTranslations(page, info);
|
await verifyPluginTranslations(page, info);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await withRetainedPlugin(page, async (probe) => {
|
||||||
|
await probe.open();
|
||||||
|
await editRetainedDraft(page, "Connected draft", 0);
|
||||||
|
|
||||||
|
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
|
||||||
|
await probe.disconnect();
|
||||||
|
await expectRetainedDraft(page, "Connected draft", 1);
|
||||||
|
await editRetainedDraft(page, "Offline draft", 1);
|
||||||
|
await probe.changeStoredTitle("Saved by another client");
|
||||||
|
await expectStoredPluginTitle(page, "Agent monitor");
|
||||||
|
await probe.reconnect();
|
||||||
|
await expectStoredPluginTitle(page, "Saved by another client");
|
||||||
|
await expectRetainedDraft(page, "Offline draft", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("inactive settings refresh without replacing the installation", async () => {
|
||||||
|
await hideRetainedPreferences(page);
|
||||||
|
await probe.disconnect();
|
||||||
|
await probe.changeStoredTitle("Saved while preferences hidden");
|
||||||
|
await probe.reconnect();
|
||||||
|
await showRetainedPreferences(page);
|
||||||
|
await expectStoredPluginTitle(page, "Saved while preferences hidden");
|
||||||
|
await expectRetainedDraft(page, "Offline draft", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("changed code replaces the retained installation after reconnect", async () => {
|
||||||
|
await probe.disconnect();
|
||||||
|
await probe.changeCode();
|
||||||
|
await expectRetainedDraft(page, "Offline draft", 2);
|
||||||
|
await probe.reconnect();
|
||||||
|
await expectChangedPlugin(page);
|
||||||
|
await editRetainedDraft(page, "New code draft", 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("removal is reconciled after reconnect", async () => {
|
||||||
|
await probe.disconnect();
|
||||||
|
await probe.remove();
|
||||||
|
await expectRetainedDraft(page, "New code draft", 1);
|
||||||
|
await probe.reconnect();
|
||||||
|
await expectRemovedPlugin(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test } from "../support/fixtures";
|
import { test } from "../support/fixtures";
|
||||||
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import {
|
import {
|
||||||
withTimelinePlugin,
|
withTimelinePlugin,
|
||||||
viewInlineThinkingExample,
|
viewInlineThinkingExample,
|
||||||
@@ -6,6 +7,8 @@ import {
|
|||||||
interactWithStreamingCard,
|
interactWithStreamingCard,
|
||||||
expectWholeCompletedCard,
|
expectWholeCompletedCard,
|
||||||
expectBothConsecutiveTools,
|
expectBothConsecutiveTools,
|
||||||
|
trackPluginChatRecovery,
|
||||||
|
reconnectPluginChat,
|
||||||
} from "../support/helpers/plugin-timeline";
|
} from "../support/helpers/plugin-timeline";
|
||||||
|
|
||||||
for (const width of [1100, 390]) {
|
for (const width of [1100, 390]) {
|
||||||
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
|
|||||||
page,
|
page,
|
||||||
}, info) => {
|
}, info) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||||
await requestPluginTimeline(agent);
|
await requestPluginTimeline(agent);
|
||||||
await interactWithStreamingCard(page);
|
await interactWithStreamingCard(page);
|
||||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||||
await expectWholeCompletedCard(page);
|
await expectWholeCompletedCard(page);
|
||||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||||
|
const recovery = trackPluginChatRecovery(page);
|
||||||
|
gate.holdTimelineResponses(agent.agentId);
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await gate.waitForHeldTimelineResponse();
|
||||||
|
await recovery.expectCatalogWithoutRetransmission();
|
||||||
|
gate.releaseHeldTimelineResponses();
|
||||||
|
await expectWholeCompletedCard(page);
|
||||||
|
await recovery.expectRecoveredWithoutRetransmission();
|
||||||
|
recovery.reset();
|
||||||
|
await reconnectPluginChat(page, gate);
|
||||||
|
await recovery.expectRecoveredWithoutRetransmission();
|
||||||
await expectWholeCompletedCard(page);
|
await expectWholeCompletedCard(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -204,19 +204,26 @@ test.describe("Projects settings", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
let projectId: string | null = null;
|
let projectViewKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
projectId = await addProjectFromSidebar(page, repo.path);
|
projectViewKey = await addProjectFromSidebar(page, repo.path);
|
||||||
await openProjectSettingsFromSidebar(page, projectId);
|
await openProjectSettingsFromSidebar(page, projectViewKey);
|
||||||
|
|
||||||
await expectProjectSettingsFormVisible(page);
|
await expectProjectSettingsFormVisible(page);
|
||||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
if (projectId) {
|
if (projectViewKey) {
|
||||||
await client.removeProject(projectId).catch(() => undefined);
|
const project = (await client.listProjects()).projects.find(
|
||||||
|
(entry) => entry.projectKey === projectViewKey,
|
||||||
|
);
|
||||||
|
expect(project).toBeDefined();
|
||||||
|
await client.removeProject(project!.projectId);
|
||||||
|
expect(
|
||||||
|
(await client.listProjects()).projects.map((entry) => entry.projectKey),
|
||||||
|
).not.toContain(projectViewKey);
|
||||||
}
|
}
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await repo.cleanup().catch(() => undefined);
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
addDirectHostFromSettings,
|
addDirectHostFromSettings,
|
||||||
expectHostOnlineWithoutError,
|
expectHostOnlineWithoutError,
|
||||||
expectIdleAgentRelease,
|
expectIdleAgentRelease,
|
||||||
|
editRestartRecoveryPrompt,
|
||||||
|
expectRestartRecoveryEnabled,
|
||||||
|
expectRestartRecoveryPrompt,
|
||||||
|
setRestartRecoveryEnabled,
|
||||||
setIdleAgentRelease,
|
setIdleAgentRelease,
|
||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
|
|
||||||
@@ -252,3 +256,20 @@ test.describe("Settings host page", () => {
|
|||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await openHostSection(page, serverId, "agents");
|
||||||
|
await expectRestartRecoveryEnabled(page, true);
|
||||||
|
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||||
|
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
|
await setRestartRecoveryEnabled(page, false);
|
||||||
|
await page.reload();
|
||||||
|
await expectRestartRecoveryEnabled(page, false);
|
||||||
|
await setRestartRecoveryEnabled(page, true);
|
||||||
|
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
|
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,11 +53,18 @@ async function quickDragFirstRowAfterSecond(
|
|||||||
await page.mouse.move(source.x, source.y + 7);
|
await page.mouse.move(source.x, source.y + 7);
|
||||||
await expect(trailingScrim).toHaveCount(0);
|
await expect(trailingScrim).toHaveCount(0);
|
||||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||||
await page.mouse.up();
|
await finishSidebarDrag(page);
|
||||||
|
|
||||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finishSidebarDrag(page: Page): Promise<void> {
|
||||||
|
await page.mouse.up();
|
||||||
|
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
|
||||||
|
// Row order updates before that blocker is removed; wait before the next user action.
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
}
|
||||||
|
|
||||||
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||||
const before = await rowTestIds(rows);
|
const before = await rowTestIds(rows);
|
||||||
const source = await visibleBoundingBox(rows.nth(0));
|
const source = await visibleBoundingBox(rows.nth(0));
|
||||||
@@ -66,7 +73,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
|
|||||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||||
await page.mouse.up();
|
await finishSidebarDrag(page);
|
||||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
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";
|
||||||
@@ -15,14 +19,27 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
|
|||||||
await openHostSection(page, daemon.serverId, "host");
|
await openHostSection(page, daemon.serverId, "host");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartDaemonInSettings(page: Page) {
|
export async function restartDaemonInSettings(
|
||||||
const button = page.getByRole("button", { name: "Restart", exact: true });
|
page: Page,
|
||||||
|
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,
|
||||||
@@ -40,3 +57,100 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settingsRpc } from "@getpaseo/plugin";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { expect, type Page } from "../fixtures";
|
||||||
|
import { gotoAppShell } from "./app";
|
||||||
|
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||||
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
|
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "retained-plugin-e2e";
|
||||||
|
|
||||||
|
export function retainedPluginSource(version: string) {
|
||||||
|
return `import { useState } from "react";
|
||||||
|
import { Text, View, TextInput, Pressable } from "react-native";
|
||||||
|
import { useSettings } from "@getpaseo/plugin/client";
|
||||||
|
import { preferences } from "./shared/preferences";
|
||||||
|
function Preferences({ color }) {
|
||||||
|
const settings = useSettings(preferences);
|
||||||
|
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
|
||||||
|
}
|
||||||
|
function Surface({ theme }) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [clicks, setClicks] = useState(0);
|
||||||
|
const [showPreferences, setShowPreferences] = useState(true);
|
||||||
|
const color = theme.colors.foreground;
|
||||||
|
return <View>
|
||||||
|
<Text style={{ color }}>Plugin version ${version}</Text>
|
||||||
|
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
|
||||||
|
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
|
||||||
|
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
|
||||||
|
{showPreferences ? <Preferences color={color} /> : null}
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
export default function contribute(plugin) {
|
||||||
|
plugin.addSurface("main", Surface);
|
||||||
|
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
|
||||||
|
return () => {};
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withRetainedPlugin(
|
||||||
|
page: Page,
|
||||||
|
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const probe = await createProbe(page);
|
||||||
|
try {
|
||||||
|
await run(probe);
|
||||||
|
} finally {
|
||||||
|
await probe.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProbe(page: Page) {
|
||||||
|
const example = await copyPluginExample("settings");
|
||||||
|
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
|
||||||
|
const previous = await peer.getDaemonConfig();
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
const clientEntry = path.join(example.directory, "index.client.tsx");
|
||||||
|
let catalogCount = 0;
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
path.join(example.directory, "paseo-plugin.json"),
|
||||||
|
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||||
|
);
|
||||||
|
await writeFile(clientEntry, retainedPluginSource("one"));
|
||||||
|
await peer.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
await peer.installDirectoryPlugin(example.directory);
|
||||||
|
} catch (error) {
|
||||||
|
await peer.close();
|
||||||
|
await example.cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async open() {
|
||||||
|
await page.setViewportSize({ width: 1100, height: 800 });
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
|
||||||
|
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
|
||||||
|
},
|
||||||
|
async disconnect() {
|
||||||
|
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
|
||||||
|
await gate.drop();
|
||||||
|
await gate.waitForBlockedConnection();
|
||||||
|
},
|
||||||
|
async reconnect() {
|
||||||
|
gate.restore();
|
||||||
|
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
|
||||||
|
},
|
||||||
|
async changeStoredTitle(title: string) {
|
||||||
|
const rpc = settingsRpc("display");
|
||||||
|
const current = rpc.read.output.parse(
|
||||||
|
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
|
||||||
|
);
|
||||||
|
if (current.status !== "ready") throw new Error("Expected ready settings");
|
||||||
|
const values = z
|
||||||
|
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
|
||||||
|
.parse(current.values);
|
||||||
|
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
|
||||||
|
revision: current.revision,
|
||||||
|
values: { ...values, title },
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ status: "saved", values: { title } });
|
||||||
|
},
|
||||||
|
async changeCode() {
|
||||||
|
await writeFile(clientEntry, retainedPluginSource("two"));
|
||||||
|
await peer.reloadPlugin(PLUGIN_ID);
|
||||||
|
},
|
||||||
|
async remove() {
|
||||||
|
await peer.removePlugin(PLUGIN_ID);
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
gate.restore();
|
||||||
|
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||||
|
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||||
|
await peer.close();
|
||||||
|
await example.cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||||
|
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
|
||||||
|
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
|
||||||
|
await expectRetainedDraft(page, draft, clicks + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||||
|
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectStoredPluginTitle(page: Page, title: string) {
|
||||||
|
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hideRetainedPreferences(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
|
||||||
|
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showRetainedPreferences(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectChangedPlugin(page: Page) {
|
||||||
|
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
|
||||||
|
await expectRetainedDraft(page, "", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRemovedPlugin(page: Page) {
|
||||||
|
await expect(
|
||||||
|
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
|
||||||
|
}
|
||||||
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
|||||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||||
|
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||||
|
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||||
|
|
||||||
const PLUGIN_ID = "timeline-transform-e2e";
|
const PLUGIN_ID = "timeline-transform-e2e";
|
||||||
|
|
||||||
|
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||||
|
export function trackPluginChatRecovery(page: Page) {
|
||||||
|
const messages: {
|
||||||
|
type: string;
|
||||||
|
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||||
|
}[] = [];
|
||||||
|
page.on("websocket", (socket) => {
|
||||||
|
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||||
|
if (typeof payload !== "string") return;
|
||||||
|
const frame = JSON.parse(payload);
|
||||||
|
if (frame.type === "session") messages.push(frame.message);
|
||||||
|
};
|
||||||
|
socket.on("framesent", collect);
|
||||||
|
socket.on("framereceived", collect);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reset() {
|
||||||
|
messages.length = 0;
|
||||||
|
},
|
||||||
|
async expectCatalogWithoutRetransmission() {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.type === "plugin.catalog.get.response" &&
|
||||||
|
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
const entry = messages
|
||||||
|
.find((message) => message.type === "plugin.catalog.get.response")
|
||||||
|
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry).not.toHaveProperty("clientBundle");
|
||||||
|
},
|
||||||
|
async expectRecoveredWithoutRetransmission() {
|
||||||
|
await this.expectCatalogWithoutRetransmission();
|
||||||
|
await expect
|
||||||
|
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||||
|
.toBe(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconnectPluginChat(
|
||||||
|
page: Page,
|
||||||
|
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||||
|
) {
|
||||||
|
await gate.drop();
|
||||||
|
await gate.waitForBlockedConnection();
|
||||||
|
await expectReconnectingToastVisible(page);
|
||||||
|
gate.restore();
|
||||||
|
}
|
||||||
export const ASSISTANT_TEXT =
|
export const ASSISTANT_TEXT =
|
||||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||||
|
|
||||||
|
|||||||
@@ -560,3 +560,41 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restartRecoverySwitch(page: Page) {
|
||||||
|
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartRecoveryEditButton(page: Page) {
|
||||||
|
return page.getByRole("button", { name: "Edit prompt", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||||
|
const toggle = restartRecoverySwitch(page);
|
||||||
|
await toggle.click();
|
||||||
|
await expectRestartRecoveryEnabled(page, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prompt is only editable while restart recovery is on.
|
||||||
|
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||||
|
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
|
||||||
|
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||||
|
await restartRecoveryEditButton(page).click();
|
||||||
|
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||||
|
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
|
||||||
|
await sheet.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||||
|
await restartRecoveryEditButton(page).click();
|
||||||
|
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||||
|
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
|
||||||
|
prompt,
|
||||||
|
);
|
||||||
|
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2778,6 +2778,14 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "استئناف الوكلاء بعد إعادة التشغيل",
|
||||||
|
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
|
||||||
|
hint: "متابعة العمل المنقطع تلقائيًا.",
|
||||||
|
prompt: "طلب الاستئناف",
|
||||||
|
edit: "تحرير",
|
||||||
|
editAccessibilityLabel: "تعديل الطلب",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "تحرير الوكلاء الخاملين",
|
title: "تحرير الوكلاء الخاملين",
|
||||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||||
@@ -2873,6 +2881,10 @@ export const ar: TranslationResources = {
|
|||||||
requestFailedTitle: "خطأ",
|
requestFailedTitle: "خطأ",
|
||||||
requestFailedMessage:
|
requestFailedMessage:
|
||||||
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
||||||
|
closeTerminals: "إعادة التشغيل وإغلاق الطرفيات",
|
||||||
|
closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.",
|
||||||
|
closeTerminalsConfirmMessage:
|
||||||
|
"سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.",
|
||||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -2870,6 +2870,14 @@ export const en = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Resume agents after restart",
|
||||||
|
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
|
||||||
|
hint: "Continue interrupted work automatically.",
|
||||||
|
prompt: "Resume prompt",
|
||||||
|
edit: "Edit",
|
||||||
|
editAccessibilityLabel: "Edit prompt",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Release idle agents",
|
title: "Release idle agents",
|
||||||
hint: "Free up memory after an hour of inactivity.",
|
hint: "Free up memory after an hour of inactivity.",
|
||||||
@@ -2966,6 +2974,10 @@ 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: {
|
||||||
|
|||||||
@@ -2800,6 +2800,14 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Reanudar agentes tras reiniciar",
|
||||||
|
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
|
||||||
|
hint: "Continuar automáticamente el trabajo interrumpido.",
|
||||||
|
prompt: "Mensaje de reanudación",
|
||||||
|
edit: "Editar",
|
||||||
|
editAccessibilityLabel: "Editar mensaje",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Liberar agentes inactivos",
|
title: "Liberar agentes inactivos",
|
||||||
hint: "Libera memoria tras una hora de inactividad.",
|
hint: "Libera memoria tras una hora de inactividad.",
|
||||||
@@ -2897,6 +2905,11 @@ 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.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2803,6 +2803,14 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Reprendre les agents après un redémarrage",
|
||||||
|
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
|
||||||
|
hint: "Continuer automatiquement le travail interrompu.",
|
||||||
|
prompt: "Message de reprise",
|
||||||
|
edit: "Modifier",
|
||||||
|
editAccessibilityLabel: "Modifier le message",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Libérer les agents inactifs",
|
title: "Libérer les agents inactifs",
|
||||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||||
@@ -2902,6 +2910,10 @@ 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.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2767,6 +2767,14 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "再起動後にエージェントを再開",
|
||||||
|
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
|
||||||
|
hint: "中断された作業を自動的に続行します。",
|
||||||
|
prompt: "再開メッセージ",
|
||||||
|
edit: "編集",
|
||||||
|
editAccessibilityLabel: "メッセージを編集",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "アイドル状態のエージェントを解放",
|
title: "アイドル状態のエージェントを解放",
|
||||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||||
@@ -2865,6 +2873,10 @@ export const ja: TranslationResources = {
|
|||||||
requestFailedTitle: "エラー",
|
requestFailedTitle: "エラー",
|
||||||
requestFailedMessage:
|
requestFailedMessage:
|
||||||
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
|
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
|
||||||
|
closeTerminals: "再起動してターミナルを閉じる",
|
||||||
|
closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。",
|
||||||
|
closeTerminalsConfirmMessage:
|
||||||
|
"デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。",
|
||||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -2755,6 +2755,14 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "재시작 후 에이전트 재개",
|
||||||
|
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
|
||||||
|
hint: "중단된 작업을 자동으로 계속합니다.",
|
||||||
|
prompt: "재개 메시지",
|
||||||
|
edit: "편집",
|
||||||
|
editAccessibilityLabel: "메시지 편집",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "유휴 에이전트 해제",
|
title: "유휴 에이전트 해제",
|
||||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||||
@@ -2852,6 +2860,10 @@ export const ko: TranslationResources = {
|
|||||||
requestFailedTitle: "오류",
|
requestFailedTitle: "오류",
|
||||||
requestFailedMessage:
|
requestFailedMessage:
|
||||||
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
|
"재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.",
|
||||||
|
closeTerminals: "재시작하고 터미널 닫기",
|
||||||
|
closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.",
|
||||||
|
closeTerminalsConfirmMessage:
|
||||||
|
"데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.",
|
||||||
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
|
dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -2782,6 +2782,14 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Conecte-se a este host para gerenciar agentes",
|
unavailable: "Conecte-se a este host para gerenciar agentes",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Retomar agentes após reiniciar",
|
||||||
|
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
|
||||||
|
hint: "Continuar automaticamente o trabalho interrompido.",
|
||||||
|
prompt: "Mensagem de retomada",
|
||||||
|
edit: "Editar",
|
||||||
|
editAccessibilityLabel: "Editar mensagem",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Liberar agentes ociosos",
|
title: "Liberar agentes ociosos",
|
||||||
hint: "Libera memória após uma hora de inatividade.",
|
hint: "Libera memória após uma hora de inatividade.",
|
||||||
@@ -2880,6 +2888,10 @@ 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: {
|
||||||
|
|||||||
@@ -2792,6 +2792,14 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Возобновлять агентов после перезапуска",
|
||||||
|
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
|
||||||
|
hint: "Автоматически продолжать прерванную работу.",
|
||||||
|
prompt: "Сообщение для продолжения",
|
||||||
|
edit: "Редактировать",
|
||||||
|
editAccessibilityLabel: "Изменить сообщение",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Освобождать неактивных агентов",
|
title: "Освобождать неактивных агентов",
|
||||||
hint: "Освобождает память после часа бездействия.",
|
hint: "Освобождает память после часа бездействия.",
|
||||||
@@ -2890,6 +2898,10 @@ export const ru: TranslationResources = {
|
|||||||
requestFailedTitle: "Ошибка",
|
requestFailedTitle: "Ошибка",
|
||||||
requestFailedMessage:
|
requestFailedMessage:
|
||||||
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
|
"Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.",
|
||||||
|
closeTerminals: "Перезапустить и закрыть терминалы",
|
||||||
|
closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.",
|
||||||
|
closeTerminalsConfirmMessage:
|
||||||
|
"Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.",
|
||||||
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
|
dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -2713,6 +2713,14 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "连接到这个 Host 以管理 Agent",
|
unavailable: "连接到这个 Host 以管理 Agent",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "重启后恢复智能体",
|
||||||
|
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
|
||||||
|
hint: "自动继续被中断的工作。",
|
||||||
|
prompt: "恢复提示",
|
||||||
|
edit: "编辑",
|
||||||
|
editAccessibilityLabel: "编辑提示",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "释放空闲 Agent",
|
title: "释放空闲 Agent",
|
||||||
hint: "空闲一小时后释放内存。",
|
hint: "空闲一小时后释放内存。",
|
||||||
@@ -2804,6 +2812,10 @@ 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: {
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import appPackage from "../../package.json";
|
||||||
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
import { QueryObserver } from "@tanstack/react-query";
|
||||||
|
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { settingsRpc } from "@getpaseo/plugin";
|
||||||
|
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||||
|
import { createPluginHosts } from "./hosts";
|
||||||
|
import { PluginRegistry } from "./registry";
|
||||||
|
import { pluginSettingsKey } from "./settings/query";
|
||||||
|
import type { InstalledPlugin } from "./types";
|
||||||
|
|
||||||
|
const audio = { play: async () => 0 };
|
||||||
|
const rpc = settingsRpc("preferences");
|
||||||
|
type Catalog = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>;
|
||||||
|
type Observation = ReturnType<DaemonClient["observeEvents"]>;
|
||||||
|
type Observer = Parameters<Observation["subscribe"]>[0];
|
||||||
|
|
||||||
|
class SettingsStore {
|
||||||
|
private revision = 1;
|
||||||
|
private values = { label: "original" };
|
||||||
|
reads = 0;
|
||||||
|
|
||||||
|
read() {
|
||||||
|
this.reads++;
|
||||||
|
return { status: "ready", revision: String(this.revision), values: this.values } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(input: unknown) {
|
||||||
|
const write = rpc.write.input.parse(input);
|
||||||
|
if (write.revision !== String(this.revision)) {
|
||||||
|
return { status: "conflict", error: "Settings changed" } as const;
|
||||||
|
}
|
||||||
|
const values = write.values;
|
||||||
|
if (typeof values !== "object" || values === null || !("label" in values)) {
|
||||||
|
throw new Error("Expected settings values");
|
||||||
|
}
|
||||||
|
if (typeof values.label !== "string") throw new Error("Expected label");
|
||||||
|
this.values = { label: values.label };
|
||||||
|
this.revision++;
|
||||||
|
return { status: "saved", revision: String(this.revision), values: this.values } as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */
|
||||||
|
class CatalogClient extends DaemonClient {
|
||||||
|
catalog = catalog("one");
|
||||||
|
fetches = 0;
|
||||||
|
releases = 0;
|
||||||
|
events: Parameters<DaemonClient["observeEvents"]>[0] = [];
|
||||||
|
private readonly observers = new Set<Observer>();
|
||||||
|
|
||||||
|
constructor(readonly settings = new SettingsStore()) {
|
||||||
|
super({ url: "ws://unused.test", clientId: "catalog-sync-test" });
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getPluginCatalog(): Promise<Catalog> {
|
||||||
|
this.fetches++;
|
||||||
|
return this.catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
override observeEvents(events: Parameters<DaemonClient["observeEvents"]>[0]): Observation {
|
||||||
|
this.events = events;
|
||||||
|
const snapshot = { requestId: "events", subscriptionId: "catalog" };
|
||||||
|
const subscribers = new Set<Observer>();
|
||||||
|
return {
|
||||||
|
subscriptionId: snapshot.subscriptionId,
|
||||||
|
ready: Promise.resolve(snapshot),
|
||||||
|
subscribe: (observer) => {
|
||||||
|
subscribers.add(observer);
|
||||||
|
this.observers.add(observer);
|
||||||
|
observer.snapshot(snapshot);
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(observer);
|
||||||
|
this.observers.delete(observer);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
release: async () => {
|
||||||
|
this.releases++;
|
||||||
|
for (const observer of subscribers) this.observers.delete(observer);
|
||||||
|
subscribers.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override async invokePluginRpc(_pluginId: string, method: string, input: unknown) {
|
||||||
|
if (method === rpc.read.name) return this.settings.read();
|
||||||
|
if (method === rpc.write.name) return this.settings.write(input);
|
||||||
|
throw new Error(`Unexpected RPC: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(message: SessionOutboundMessage) {
|
||||||
|
for (const observer of this.observers) observer.update(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
for (const observer of this.observers) {
|
||||||
|
observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog(marker: string): Catalog {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "reports",
|
||||||
|
requirements: { paseo: `>=${appPackage.version}` },
|
||||||
|
clientBundle: `(function() { return { default: function(plugin) {
|
||||||
|
plugin.addTimelineTransformer({
|
||||||
|
id: "report", query: { itemType: "tool_call" },
|
||||||
|
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; }
|
||||||
|
});
|
||||||
|
return function() { plugin.openSettings("cleanup"); };
|
||||||
|
} }; })`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const cleanup of cleanups.splice(0).toReversed()) cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
let evaluations = 0;
|
||||||
|
let disposals = 0;
|
||||||
|
const registry = new PluginRegistry({
|
||||||
|
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
|
||||||
|
version: appPackage.version,
|
||||||
|
createRuntime(installation) {
|
||||||
|
evaluations++;
|
||||||
|
return {
|
||||||
|
hosts: createPluginHosts(
|
||||||
|
{
|
||||||
|
getHosts: () => [],
|
||||||
|
getSnapshot: () => null,
|
||||||
|
subscribeAll: () => () => {},
|
||||||
|
subscribeHostList: () => () => {},
|
||||||
|
},
|
||||||
|
installation.lifetime.signal,
|
||||||
|
),
|
||||||
|
paseo: installation.paseo,
|
||||||
|
i18n: installation.i18n,
|
||||||
|
rpc: async () => {
|
||||||
|
throw new Error("Unexpected runtime RPC");
|
||||||
|
},
|
||||||
|
openSettings: () => {
|
||||||
|
disposals++;
|
||||||
|
},
|
||||||
|
openScreen() {},
|
||||||
|
openSurface() {},
|
||||||
|
async playAudio() {},
|
||||||
|
openPanel() {},
|
||||||
|
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||||
|
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const client = new CatalogClient();
|
||||||
|
cleanups.push(() => {
|
||||||
|
registry.removeHost("host");
|
||||||
|
registry.removeHost("other");
|
||||||
|
});
|
||||||
|
function sync(options: Partial<Parameters<typeof syncPluginCatalog>[0]> = {}) {
|
||||||
|
const stop = syncPluginCatalog({
|
||||||
|
serverId: "host",
|
||||||
|
client,
|
||||||
|
audio,
|
||||||
|
connected: true,
|
||||||
|
supported: true,
|
||||||
|
registry,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
cleanups.push(stop);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
|
function installed(serverId = "host") {
|
||||||
|
const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId);
|
||||||
|
if (!plugin) throw new Error(`Missing installation: ${serverId}`);
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
client,
|
||||||
|
sync,
|
||||||
|
installed,
|
||||||
|
evaluations: () => evaluations,
|
||||||
|
disposals: () => disposals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connected() {
|
||||||
|
const h = harness();
|
||||||
|
const stop = h.sync();
|
||||||
|
await expect.poll(() => h.registry.getSnapshot().length).toBe(1);
|
||||||
|
return { ...h, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsQuery(plugin: InstalledPlugin) {
|
||||||
|
return {
|
||||||
|
queryKey: pluginSettingsKey("preferences"),
|
||||||
|
queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})),
|
||||||
|
// The replica policy used by useSettings deliberately disables automatic refetches.
|
||||||
|
gcTime: Infinity,
|
||||||
|
staleTime: Infinity,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("retains installations and timeline transforms while disconnected", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
const snapshot = h.registry.getSnapshot();
|
||||||
|
const transformers = plugin.timelineTransformers;
|
||||||
|
h.stop();
|
||||||
|
h.sync({ connected: false });
|
||||||
|
h.client.update({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||||
|
});
|
||||||
|
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||||
|
expect(h.installed().timelineTransformers).toBe(transformers);
|
||||||
|
expect(plugin.lifetime.signal.aborted).toBe(false);
|
||||||
|
expect(h.disposals()).toBe(0);
|
||||||
|
expect(h.client.fetches).toBe(1);
|
||||||
|
expect(h.client.events).toEqual([
|
||||||
|
"status.plugin_catalog_changed",
|
||||||
|
"status.plugin_settings_changed",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["mounted", "unmounted"])(
|
||||||
|
"refreshes %s settings and revisions after another client saves offline",
|
||||||
|
async (surface) => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
const snapshot = h.registry.getSnapshot();
|
||||||
|
const options = settingsQuery(plugin);
|
||||||
|
const original = await plugin.queryClient.fetchQuery(options);
|
||||||
|
const observer = new QueryObserver(plugin.queryClient, options);
|
||||||
|
const unsubscribe = observer.subscribe(() => {});
|
||||||
|
cleanups.push(unsubscribe);
|
||||||
|
if (surface === "unmounted") unsubscribe();
|
||||||
|
plugin.queryClient.setQueryData(["attachment-search"], "retained");
|
||||||
|
const otherClient = new CatalogClient();
|
||||||
|
h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio });
|
||||||
|
const otherPlugin = h.installed("other");
|
||||||
|
await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin));
|
||||||
|
// Another host publishes new presentation snapshots without replacing the owning lifetime.
|
||||||
|
const beforeReconnect = h.registry.getSnapshot();
|
||||||
|
const retainedInstallation = h.installed();
|
||||||
|
|
||||||
|
h.stop();
|
||||||
|
h.sync({ connected: false });
|
||||||
|
const clientB = new CatalogClient(h.client.settings);
|
||||||
|
expect(
|
||||||
|
await clientB.invokePluginRpc("reports", rpc.write.name, {
|
||||||
|
revision: original.revision,
|
||||||
|
values: { label: "client B" },
|
||||||
|
}),
|
||||||
|
).toEqual({ status: "saved", revision: "2", values: { label: "client B" } });
|
||||||
|
expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||||
|
h.sync();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||||
|
.toEqual({
|
||||||
|
status: "ready",
|
||||||
|
revision: "2",
|
||||||
|
values: { label: "client B" },
|
||||||
|
});
|
||||||
|
const refreshed = await plugin.queryClient.fetchQuery(options);
|
||||||
|
expect(
|
||||||
|
await plugin.invoke(rpc.write.name, {
|
||||||
|
revision: refreshed.revision,
|
||||||
|
values: { label: "client A" },
|
||||||
|
}),
|
||||||
|
).toEqual({ status: "saved", revision: "3", values: { label: "client A" } });
|
||||||
|
expect(h.registry.getSnapshot()).toBe(beforeReconnect);
|
||||||
|
expect(h.installed()).toBe(retainedInstallation);
|
||||||
|
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||||
|
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||||
|
expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers);
|
||||||
|
expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained");
|
||||||
|
expect(otherClient.settings.reads).toBe(1);
|
||||||
|
expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false);
|
||||||
|
expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||||
|
expect(h.evaluations()).toBe(2);
|
||||||
|
expect(h.disposals()).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("replaces changed bundles on reconnect and disposes the old installation", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
plugin.queryClient.setQueryData(["attachment-search"], "old");
|
||||||
|
h.stop();
|
||||||
|
h.client.catalog = catalog("two");
|
||||||
|
h.sync();
|
||||||
|
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||||
|
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||||
|
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||||
|
expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]);
|
||||||
|
expect(h.evaluations()).toBe(2);
|
||||||
|
expect(h.disposals()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
h.client.update({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||||
|
});
|
||||||
|
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||||
|
expect(h.disposals()).toBe(1);
|
||||||
|
expect(h.evaluations()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
h.stop();
|
||||||
|
h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio });
|
||||||
|
expect(h.registry.getSnapshot()).toEqual([]);
|
||||||
|
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||||
|
expect(h.disposals()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
const options = settingsQuery(plugin);
|
||||||
|
await plugin.queryClient.fetchQuery(options);
|
||||||
|
h.client.settings.write({ revision: "1", values: { label: "replayed" } });
|
||||||
|
h.client.snapshot();
|
||||||
|
await expect
|
||||||
|
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||||
|
.toEqual({
|
||||||
|
status: "ready",
|
||||||
|
revision: "2",
|
||||||
|
values: { label: "replayed" },
|
||||||
|
});
|
||||||
|
expect(h.installed()).toBe(plugin);
|
||||||
|
expect(h.evaluations()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates only the named settings on its host for a live notification", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
const options = settingsQuery(plugin);
|
||||||
|
await plugin.queryClient.fetchQuery(options);
|
||||||
|
const observer = new QueryObserver(plugin.queryClient, options);
|
||||||
|
cleanups.push(observer.subscribe(() => {}));
|
||||||
|
plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged");
|
||||||
|
const other = new CatalogClient();
|
||||||
|
h.registry.installCatalog("other", other.catalog, { client: other, audio });
|
||||||
|
await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other")));
|
||||||
|
|
||||||
|
const beforeNotification = h.registry.getSnapshot();
|
||||||
|
const retainedInstallation = h.installed();
|
||||||
|
h.client.settings.write({ revision: "1", values: { label: "live" } });
|
||||||
|
h.client.update({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "plugin_settings_changed",
|
||||||
|
pluginId: "reports",
|
||||||
|
settingsId: "preferences",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||||
|
.toEqual({
|
||||||
|
status: "ready",
|
||||||
|
revision: "2",
|
||||||
|
values: { label: "live" },
|
||||||
|
});
|
||||||
|
expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(other.settings.reads).toBe(1);
|
||||||
|
expect(h.registry.getSnapshot()).toBe(beforeNotification);
|
||||||
|
expect(h.installed()).toBe(retainedInstallation);
|
||||||
|
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||||
|
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||||
|
expect(h.evaluations()).toBe(2);
|
||||||
|
expect(h.client.fetches).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an in-flight catalog snapshot after disconnect", async () => {
|
||||||
|
const h = await connected();
|
||||||
|
const plugin = h.installed();
|
||||||
|
await plugin.queryClient.fetchQuery(settingsQuery(plugin));
|
||||||
|
const snapshot = h.registry.getSnapshot();
|
||||||
|
h.stop();
|
||||||
|
let resolveCatalog: (value: Catalog) => void = () => {};
|
||||||
|
const pending = new Promise<Catalog>((resolve) => {
|
||||||
|
resolveCatalog = resolve;
|
||||||
|
});
|
||||||
|
let fetching: () => void = () => {};
|
||||||
|
const started = new Promise<void>((resolve) => {
|
||||||
|
fetching = resolve;
|
||||||
|
});
|
||||||
|
class PendingClient extends CatalogClient {
|
||||||
|
override getPluginCatalog() {
|
||||||
|
fetching();
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stop = h.sync({ client: new PendingClient(h.client.settings) });
|
||||||
|
await started;
|
||||||
|
stop();
|
||||||
|
resolveCatalog(catalog("late"));
|
||||||
|
await pending;
|
||||||
|
// Drain the catalog continuation through a subsequently queued task.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||||
|
expect(h.client.settings.reads).toBe(1);
|
||||||
|
expect(h.evaluations()).toBe(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { AudioEngine } from "@/audio";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { PluginRegistry } from "./registry";
|
||||||
|
import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query";
|
||||||
|
|
||||||
|
interface CatalogSyncInput {
|
||||||
|
serverId: string;
|
||||||
|
client: DaemonClient;
|
||||||
|
audio: Pick<AudioEngine, "play"> | null;
|
||||||
|
connected: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncPluginCatalog({
|
||||||
|
serverId,
|
||||||
|
client,
|
||||||
|
audio,
|
||||||
|
connected,
|
||||||
|
supported,
|
||||||
|
registry,
|
||||||
|
}: CatalogSyncInput): () => void {
|
||||||
|
if (!supported || !audio) {
|
||||||
|
registry.removeHost(serverId);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
// A dropped socket reconnects on the same client, so installations keep rendering offline.
|
||||||
|
if (!connected) return () => {};
|
||||||
|
const installationOptions = { client, audio };
|
||||||
|
let cancelled = false;
|
||||||
|
let refreshQueue = Promise.resolve();
|
||||||
|
function refresh(replacePluginId?: string) {
|
||||||
|
refreshQueue = refreshQueue.then(() =>
|
||||||
|
client
|
||||||
|
.getPluginCatalog()
|
||||||
|
.then((catalog) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return refreshQueue;
|
||||||
|
}
|
||||||
|
const observation = client.observeEvents([
|
||||||
|
"status.plugin_catalog_changed",
|
||||||
|
"status.plugin_settings_changed",
|
||||||
|
]);
|
||||||
|
observation.subscribe({
|
||||||
|
snapshot: () => {
|
||||||
|
void refresh().then(() => {
|
||||||
|
if (cancelled) return undefined;
|
||||||
|
// Retained replicas miss offline writes and do not refetch when a screen mounts.
|
||||||
|
for (const plugin of registry.getSnapshot()) {
|
||||||
|
if (plugin.serverId !== serverId) continue;
|
||||||
|
void plugin.queryClient.invalidateQueries({
|
||||||
|
queryKey: pluginSettingsPrefix,
|
||||||
|
refetchType: "all",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
update: (message) => {
|
||||||
|
if (message.type !== "status") return;
|
||||||
|
if (message.payload.status === "plugin_settings_changed") {
|
||||||
|
const { pluginId, settingsId } = message.payload;
|
||||||
|
if (typeof settingsId === "string") {
|
||||||
|
const plugin = registry
|
||||||
|
.getSnapshot()
|
||||||
|
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||||
|
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.payload.status === "plugin_catalog_changed") {
|
||||||
|
const pluginId = message.payload.pluginId;
|
||||||
|
if (typeof pluginId === "string") void refresh(pluginId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void observation
|
||||||
|
.release()
|
||||||
|
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import { pluginSettingsKey } from "./settings/use-settings";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { pluginRegistry } from "./registry";
|
import { pluginRegistry } from "./registry";
|
||||||
|
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||||
|
|
||||||
export function PluginCatalogSync({
|
export function PluginCatalogSync({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -17,72 +17,18 @@ export function PluginCatalogSync({
|
|||||||
const connected = useHostRuntimeIsConnected(serverId);
|
const connected = useHostRuntimeIsConnected(serverId);
|
||||||
const supported = useHostFeature(serverId, "plugins");
|
const supported = useHostFeature(serverId, "plugins");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(
|
||||||
let cancelled = false;
|
() =>
|
||||||
let refreshQueue = Promise.resolve();
|
syncPluginCatalog({
|
||||||
if (!supported || !audio) {
|
serverId,
|
||||||
pluginRegistry.removeHost(serverId);
|
client,
|
||||||
return;
|
audio,
|
||||||
}
|
connected,
|
||||||
if (!connected) {
|
supported,
|
||||||
pluginRegistry.removeHost(serverId);
|
registry: pluginRegistry,
|
||||||
return;
|
}),
|
||||||
}
|
[audio, client, connected, serverId, supported],
|
||||||
const refresh = (replacePluginId?: string) => {
|
);
|
||||||
refreshQueue = refreshQueue.then(() =>
|
|
||||||
client
|
|
||||||
.getPluginCatalog()
|
|
||||||
.then((catalog) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
pluginRegistry.installCatalog(serverId, catalog, {
|
|
||||||
replacePluginId,
|
|
||||||
client,
|
|
||||||
audio,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return refreshQueue;
|
|
||||||
};
|
|
||||||
const observation = client.observeEvents([
|
|
||||||
"status.plugin_catalog_changed",
|
|
||||||
"status.plugin_settings_changed",
|
|
||||||
]);
|
|
||||||
observation.subscribe({
|
|
||||||
snapshot: () => {
|
|
||||||
void refresh();
|
|
||||||
},
|
|
||||||
update: (message) => {
|
|
||||||
if (message.type !== "status") return;
|
|
||||||
if (message.payload.status === "plugin_settings_changed") {
|
|
||||||
const { pluginId, settingsId } = message.payload;
|
|
||||||
if (typeof settingsId === "string") {
|
|
||||||
const plugin = pluginRegistry
|
|
||||||
.getSnapshot()
|
|
||||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
|
||||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (message.payload.status === "plugin_catalog_changed") {
|
|
||||||
const pluginId = message.payload.pluginId;
|
|
||||||
if (typeof pluginId === "string") void refresh(pluginId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
void observation
|
|
||||||
.release()
|
|
||||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
|
||||||
};
|
|
||||||
}, [audio, client, connected, serverId, supported]);
|
|
||||||
|
|
||||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as FileSystem from "expo-file-system/legacy";
|
||||||
|
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||||
|
import { createPluginClientBundleStore } from "./store";
|
||||||
|
|
||||||
|
export function createHostPluginClientBundleStore(serverId: string) {
|
||||||
|
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||||
|
const ensureDirectory = async () => {
|
||||||
|
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||||
|
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||||
|
};
|
||||||
|
return createPluginClientBundleStore(
|
||||||
|
{
|
||||||
|
async list() {
|
||||||
|
await ensureDirectory();
|
||||||
|
return FileSystem.readDirectoryAsync(directory);
|
||||||
|
},
|
||||||
|
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||||
|
async writeAtomic(name, contents) {
|
||||||
|
await ensureDirectory();
|
||||||
|
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||||
|
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||||
|
} finally {
|
||||||
|
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||||
|
},
|
||||||
|
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createPluginClientBundleStore } from "./store";
|
||||||
|
|
||||||
|
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||||
|
const STORE_NAME = "bundles";
|
||||||
|
let opening: Promise<IDBDatabase> | undefined;
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||||
|
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||||
|
request.addEventListener("success", () => resolve(request.result));
|
||||||
|
request.addEventListener("error", () => reject(request.error));
|
||||||
|
}).catch((error) => {
|
||||||
|
opening = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return opening;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostPluginClientBundleStore(serverId: string) {
|
||||||
|
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||||
|
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||||
|
async function run<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORE_NAME, mode);
|
||||||
|
const request = operation(transaction.objectStore(STORE_NAME));
|
||||||
|
transaction.addEventListener("complete", () => resolve(request.result));
|
||||||
|
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||||
|
transaction.addEventListener("error", () => reject(transaction.error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createPluginClientBundleStore(
|
||||||
|
{
|
||||||
|
async list() {
|
||||||
|
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||||
|
.filter((key): key is string => typeof key === "string")
|
||||||
|
.map((key) => key.slice(prefix.length));
|
||||||
|
},
|
||||||
|
async read(name) {
|
||||||
|
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||||
|
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async writeAtomic(name, contents) {
|
||||||
|
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||||
|
},
|
||||||
|
async remove(name) {
|
||||||
|
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (contents) => {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createHostPluginClientBundleStore } from "./index.web";
|
||||||
|
|
||||||
|
async function sha256(contents: string) {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||||
|
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||||
|
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||||
|
const clientBundle = "bundle".repeat(250_000);
|
||||||
|
const entry = {
|
||||||
|
id: "same-plugin",
|
||||||
|
clientBundle,
|
||||||
|
clientBundleHash: await sha256(clientBundle),
|
||||||
|
};
|
||||||
|
const otherCode = "other host code";
|
||||||
|
const other = {
|
||||||
|
id: entry.id,
|
||||||
|
clientBundle: otherCode,
|
||||||
|
clientBundleHash: await sha256(otherCode),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await first.replace([entry]);
|
||||||
|
await second.replace([other]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||||
|
await first.replace([]);
|
||||||
|
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||||
|
expect(await second.load()).toEqual([other]);
|
||||||
|
} finally {
|
||||||
|
await first.replace([]);
|
||||||
|
await second.replace([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||||
|
|
||||||
|
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||||
|
|
||||||
|
class BundleFiles implements PluginBundleFiles {
|
||||||
|
readonly contents = new Map<string, string>();
|
||||||
|
readonly writes: string[] = [];
|
||||||
|
async list() {
|
||||||
|
return [...this.contents.keys()];
|
||||||
|
}
|
||||||
|
async read(name: string) {
|
||||||
|
const contents = this.contents.get(name);
|
||||||
|
if (contents === undefined) throw new Error("File disappeared");
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
async writeAtomic(name: string, contents: string) {
|
||||||
|
this.writes.push(name);
|
||||||
|
this.contents.set(name, contents);
|
||||||
|
}
|
||||||
|
async remove(name: string) {
|
||||||
|
this.contents.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundle(id: string, clientBundle: string) {
|
||||||
|
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const old = await bundle("changed", "old");
|
||||||
|
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||||
|
const cold = createPluginClientBundleStore(files, sha256);
|
||||||
|
expect(await cold.load()).toEqual([old, kept]);
|
||||||
|
const changed = await bundle("changed", "new");
|
||||||
|
await cold.replace([kept, changed]);
|
||||||
|
expect(files.contents.size).toBe(2);
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||||
|
expect(files.writes).toHaveLength(3);
|
||||||
|
await cold.replace([]);
|
||||||
|
expect(files.contents.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const entries = await Promise.all([
|
||||||
|
bundle("same-length", "good"),
|
||||||
|
bundle("truncated", "long"),
|
||||||
|
bundle("missing", "lost"),
|
||||||
|
]);
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||||
|
const names = [...files.contents.keys()];
|
||||||
|
files.contents.set(names[0]!, "evil");
|
||||||
|
files.contents.set(names[1]!, "lo");
|
||||||
|
// The directory entry still exists while reading the file fails.
|
||||||
|
const unreadable: PluginBundleFiles = {
|
||||||
|
list: () => files.list(),
|
||||||
|
read: (name) =>
|
||||||
|
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||||
|
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||||
|
remove: (name) => files.remove(name),
|
||||||
|
};
|
||||||
|
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||||
|
expect(await cold.load()).toEqual([]);
|
||||||
|
await cold.replace(entries);
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const previous = await bundle("plugin", "previous");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||||
|
const failing: PluginBundleFiles = {
|
||||||
|
list: () => files.list(),
|
||||||
|
read: (name) => files.read(name),
|
||||||
|
remove: (name) => files.remove(name),
|
||||||
|
writeAtomic: async () => {
|
||||||
|
throw new Error("Disk full");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||||
|
).rejects.toThrow("Disk full");
|
||||||
|
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||||
|
test.each(["valid", "corrupt", "missing"])(
|
||||||
|
"cold catalog uses a %s persisted bundle correctly",
|
||||||
|
async (state) => {
|
||||||
|
const files = new BundleFiles();
|
||||||
|
const entry = await bundle("plugin", "good code");
|
||||||
|
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||||
|
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||||
|
if (state === "missing") files.contents.clear();
|
||||||
|
let open = () => {};
|
||||||
|
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||||
|
const requests: Record<string, unknown>[] = [];
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "plugin-cache-test",
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||||
|
transportFactory: () => ({
|
||||||
|
send(data) {
|
||||||
|
if (typeof data !== "string") return;
|
||||||
|
const frame = JSON.parse(data);
|
||||||
|
if (frame.type !== "session") return;
|
||||||
|
const request = frame.message;
|
||||||
|
if (request.type !== "plugin.catalog.get.request") return;
|
||||||
|
requests.push(request);
|
||||||
|
const { clientBundle: _code, ...reference } = entry;
|
||||||
|
receive(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "plugin.catalog.get.response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
plugins: [request.cachedClientBundles ? reference : entry],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
onOpen(handler) {
|
||||||
|
open = handler;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onMessage(handler) {
|
||||||
|
receive = handler;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onClose() {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const connecting = client.connect();
|
||||||
|
open();
|
||||||
|
receive(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "cache-host",
|
||||||
|
hostname: null,
|
||||||
|
version: null,
|
||||||
|
features: { plugins: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await connecting;
|
||||||
|
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||||
|
if (state === "valid")
|
||||||
|
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||||
|
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||||
|
]);
|
||||||
|
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||||
|
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
PluginClientBundle,
|
||||||
|
PluginClientBundleStore,
|
||||||
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
|
|
||||||
|
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||||
|
export interface PluginBundleFiles {
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
read(name: string): Promise<string>;
|
||||||
|
writeAtomic(name: string, contents: string): Promise<void>;
|
||||||
|
remove(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginClientBundleStore(
|
||||||
|
files: PluginBundleFiles,
|
||||||
|
sha256: (contents: string) => Promise<string>,
|
||||||
|
): PluginClientBundleStore {
|
||||||
|
const verified = new Set<string>();
|
||||||
|
const filename = (bundle: PluginClientBundle) =>
|
||||||
|
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||||
|
return {
|
||||||
|
async load() {
|
||||||
|
const bundles: PluginClientBundle[] = [];
|
||||||
|
for (const name of await files.list()) {
|
||||||
|
try {
|
||||||
|
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||||
|
if (!match) continue;
|
||||||
|
const id = decodeURIComponent(match[1]!);
|
||||||
|
const clientBundleHash = match[2]!;
|
||||||
|
const clientBundle = await files.read(name);
|
||||||
|
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||||
|
verified.add(name);
|
||||||
|
bundles.push({ id, clientBundleHash, clientBundle });
|
||||||
|
} catch {
|
||||||
|
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bundles;
|
||||||
|
},
|
||||||
|
async replace(bundles) {
|
||||||
|
const retained = new Set<string>();
|
||||||
|
for (const bundle of bundles) {
|
||||||
|
const name = filename(bundle);
|
||||||
|
retained.add(name);
|
||||||
|
if (verified.has(name)) continue;
|
||||||
|
await files.writeAtomic(name, bundle.clientBundle);
|
||||||
|
verified.add(name);
|
||||||
|
}
|
||||||
|
for (const name of await files.list()) {
|
||||||
|
if (retained.has(name)) continue;
|
||||||
|
await files.remove(name);
|
||||||
|
verified.delete(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -219,7 +219,10 @@ test("locale snapshots preserve installation state and observations, then releas
|
|||||||
expect(after.paseo).toBe(before.paseo);
|
expect(after.paseo).toBe(before.paseo);
|
||||||
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
||||||
expect(h.open).toEqual(new Set(["notes"]));
|
expect(h.open).toEqual(new Set(["notes"]));
|
||||||
|
const translatedSnapshot = h.plugins.getSnapshot();
|
||||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||||
|
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
|
||||||
|
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
|
||||||
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
||||||
h.plugins.removeHost("host");
|
h.plugins.removeHost("host");
|
||||||
expect(listeners.size).toBe(0);
|
expect(listeners.size).toBe(0);
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ const pluginRegistry = {
|
|||||||
installCatalog(
|
installCatalog(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
catalog: Parameters<typeof registry.installCatalog>[1],
|
||||||
options: { replacePluginId?: string } = {},
|
options: { replacePluginId?: string; client?: DaemonClient } = {},
|
||||||
) {
|
) {
|
||||||
return registry.installCatalog(
|
return registry.installCatalog(
|
||||||
serverId,
|
serverId,
|
||||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||||
{ ...options, client: daemonClient, audio },
|
{ client: daemonClient, ...options, audio },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -118,6 +118,37 @@ describe("PluginRegistry", () => {
|
|||||||
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the snapshot when the same catalog is installed again", () => {
|
||||||
|
const clientBundle = timelineBundle("one");
|
||||||
|
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||||
|
const snapshot = pluginRegistry.getSnapshot();
|
||||||
|
const transformers = snapshot[0]?.timelineTransformers;
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = pluginRegistry.subscribe(listener);
|
||||||
|
|
||||||
|
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
|
||||||
|
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reinstalls an unchanged bundle for a new host client", () => {
|
||||||
|
const clientBundle = bundle("one");
|
||||||
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||||
|
const first = pluginRegistry.getSnapshot()[0];
|
||||||
|
|
||||||
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
||||||
|
client: {} as DaemonClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = pluginRegistry.getSnapshot()[0];
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(first?.lifetime.signal.aborted).toBe(true);
|
||||||
|
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("replaces the query cache when the plugin bundle changes", () => {
|
it("replaces the query cache when the plugin bundle changes", () => {
|
||||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||||
const first = pluginRegistry.getSnapshot()[0];
|
const first = pluginRegistry.getSnapshot()[0];
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export class PluginRegistry {
|
|||||||
private snapshot: InstalledPlugin[] = [];
|
private snapshot: InstalledPlugin[] = [];
|
||||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||||
private readonly evaluationErrors = new Map<string, string>();
|
private readonly evaluationErrors = new Map<string, string>();
|
||||||
|
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
|
||||||
|
private readonly clientByHost = new Map<string, DaemonClient>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dependencies: {
|
private readonly dependencies: {
|
||||||
@@ -49,10 +51,15 @@ export class PluginRegistry {
|
|||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
const previous = this.byHost.get(serverId) ?? [];
|
const previous = this.byHost.get(serverId) ?? [];
|
||||||
|
const previousErrors = this.hostEvaluationErrors(serverId);
|
||||||
|
// Installations call the client they were created with, so a new client replaces them all.
|
||||||
|
const sameClient = this.clientByHost.get(serverId) === options.client;
|
||||||
|
this.clientByHost.set(serverId, options.client);
|
||||||
const previousTimelineBundles = previous
|
const previousTimelineBundles = previous
|
||||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||||
const preserved = catalog.flatMap((entry) => {
|
const preserved = catalog.flatMap((entry) => {
|
||||||
|
if (!sameClient) return [];
|
||||||
const existing = previous.find(
|
const existing = previous.find(
|
||||||
(plugin) =>
|
(plugin) =>
|
||||||
plugin.id !== options.replacePluginId &&
|
plugin.id !== options.replacePluginId &&
|
||||||
@@ -146,13 +153,13 @@ export class PluginRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.byHost.set(serverId, installed);
|
this.byHost.set(serverId, installed);
|
||||||
if (
|
// An unchanged catalog keeps the snapshot, so timeline projections cached against the
|
||||||
installed.length !== catalog.length ||
|
// installed transformers stay valid across a reconnect.
|
||||||
installed.length !== previous.length ||
|
const unchanged =
|
||||||
installed.some((plugin, index) => plugin !== previous[index])
|
installed.length === previous.length &&
|
||||||
) {
|
installed.every((plugin, index) => plugin === previous[index]) &&
|
||||||
this.publish();
|
this.hostEvaluationErrors(serverId) === previousErrors;
|
||||||
}
|
if (!unchanged) this.publish();
|
||||||
const installedTimelineBundles = installed
|
const installedTimelineBundles = installed
|
||||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||||
@@ -170,9 +177,17 @@ export class PluginRegistry {
|
|||||||
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
||||||
}
|
}
|
||||||
this.byHost.delete(serverId);
|
this.byHost.delete(serverId);
|
||||||
|
this.clientByHost.delete(serverId);
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hostEvaluationErrors(serverId: string): string {
|
||||||
|
const prefix = `${serverId}/`;
|
||||||
|
return JSON.stringify(
|
||||||
|
[...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private dispose(plugin: InstalledPlugin): void {
|
private dispose(plugin: InstalledPlugin): void {
|
||||||
if (this.disposed.has(plugin)) return;
|
if (this.disposed.has(plugin)) return;
|
||||||
this.disposed.add(plugin);
|
this.disposed.add(plugin);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const pluginSettingsPrefix = ["plugin-settings"] as const;
|
||||||
|
export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const;
|
||||||
@@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin";
|
|||||||
import { settingsRpc } from "@getpaseo/plugin";
|
import { settingsRpc } from "@getpaseo/plugin";
|
||||||
import { useReplicaQuery } from "@/data/query";
|
import { useReplicaQuery } from "@/data/query";
|
||||||
import { z, type ZodType } from "zod";
|
import { z, type ZodType } from "zod";
|
||||||
|
import { pluginSettingsKey } from "./query";
|
||||||
|
|
||||||
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
|
|
||||||
function message(error: unknown): string {
|
function message(error: unknown): string {
|
||||||
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
|
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||||
import { watchdog } from "@/watchdog";
|
import { watchdog } from "@/watchdog";
|
||||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||||
import type { OwnedSubscription } from "@getpaseo/client";
|
import type { OwnedSubscription } from "@getpaseo/client";
|
||||||
@@ -589,6 +590,7 @@ function createDefaultDeps(
|
|||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||||
providerSnapshots: "wire",
|
providerSnapshots: "wire",
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
...(host.password ? { password: host.password } : {}),
|
...(host.password ? { password: host.password } : {}),
|
||||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||||
} satisfies Omit<DaemonClientConfig, "url">;
|
} satisfies Omit<DaemonClientConfig, "url">;
|
||||||
@@ -668,6 +670,7 @@ function createDefaultDeps(
|
|||||||
),
|
),
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
appVersion: resolveAppVersion() ?? undefined,
|
appVersion: resolveAppVersion() ?? undefined,
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -684,6 +687,7 @@ function createDefaultDeps(
|
|||||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||||
|
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getClientId: () => getOrCreateClientId(),
|
getClientId: () => getOrCreateClientId(),
|
||||||
|
|||||||
@@ -12,6 +12,69 @@ 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,35 +1,61 @@
|
|||||||
|
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) => Promise<unknown>;
|
restartServer: (reason: string, options: { killTerminals: boolean }) => 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);
|
await deps.restartServer(reason, options);
|
||||||
acknowledged = true;
|
acknowledged = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isReconnectFailure(error)) throw error;
|
if (!isReconnectFailure(error)) throw error;
|
||||||
}
|
}
|
||||||
|
let replacement: WorkerStatus;
|
||||||
try {
|
try {
|
||||||
await observeReplacement(previous, deps);
|
replacement = 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(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme";
|
|||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useProviderIcon } from "@/components/provider-icons";
|
import { useProviderIcon } from "@/components/provider-icons";
|
||||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||||
|
import { RestartRecoveryCard } from "./restart-recovery-card";
|
||||||
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
||||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
|||||||
<InjectPaseoToolsCard serverId={serverId} />
|
<InjectPaseoToolsCard serverId={serverId} />
|
||||||
<BrowserToolsOptInCard serverId={serverId} />
|
<BrowserToolsOptInCard serverId={serverId} />
|
||||||
<AppendSystemPromptCard serverId={serverId} />
|
<AppendSystemPromptCard serverId={serverId} />
|
||||||
|
<RestartRecoveryCard serverId={serverId} />
|
||||||
<IdleRuntimeCleanupCard serverId={serverId} />
|
<IdleRuntimeCleanupCard serverId={serverId} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : (
|
) : (
|
||||||
@@ -565,7 +567,15 @@ 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 () => {
|
||||||
@@ -578,71 +588,80 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
|||||||
[host.serverId, runtime],
|
[host.serverId, runtime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const waitForDaemonRestart = useCallback(
|
const waitForDaemonRestart = useCallback(async (request: Promise<void>) => {
|
||||||
async (request: Promise<void>) => {
|
try {
|
||||||
try {
|
await request;
|
||||||
await request;
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error("[HostPage] Daemon restart failed", error);
|
||||||
Alert.alert(
|
if (isMountedRef.current) {
|
||||||
t("settings.host.daemon.restart.requestFailedTitle"),
|
setRestartError(error instanceof Error ? error.message : String(error));
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) setIsRestarting(false);
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) setIsRestarting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRestart = useCallback(
|
||||||
|
(killTerminals: boolean) => {
|
||||||
|
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: 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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[t],
|
[daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRestart = useCallback(() => {
|
const handleRestart = useCallback(() => startRestart(false), [startRestart]);
|
||||||
if (!daemonClient) {
|
const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]);
|
||||||
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">
|
||||||
@@ -664,6 +683,35 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -848,7 +896,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{updateState.status === "failed" ? (
|
{updateState.status === "failed" ? (
|
||||||
<View style={styles.updateFailure}>
|
<View style={styles.cardAlert}>
|
||||||
<InlineAlert
|
<InlineAlert
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="error"
|
variant="error"
|
||||||
@@ -1702,7 +1750,7 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
updateFailure: {
|
cardAlert: {
|
||||||
marginHorizontal: theme.spacing[4],
|
marginHorizontal: theme.spacing[4],
|
||||||
marginBottom: theme.spacing[4],
|
marginBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
|
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
|
||||||
|
const DEFAULT_RESUME_PROMPT = "Resume working.";
|
||||||
|
|
||||||
|
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
|
||||||
|
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
|
||||||
|
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
|
||||||
|
const result = await patchConfig(next);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
mutation.mutate({ resumeOnStartup: next });
|
||||||
|
},
|
||||||
|
[mutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
mutation.reset();
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [mutation]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (mutation.isPending) return;
|
||||||
|
mutation.reset();
|
||||||
|
setIsEditing(false);
|
||||||
|
}, [mutation]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
(prompt: string) => {
|
||||||
|
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
|
||||||
|
},
|
||||||
|
[mutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isConnected || !isSupported || !config) return null;
|
||||||
|
|
||||||
|
const isEnabled = config.resumeOnStartup !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
</Text>
|
||||||
|
<SettingsInfoTip
|
||||||
|
title={t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
info={t("settings.host.agents.resumeOnStartup.info")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.hint")}
|
||||||
|
</Text>
|
||||||
|
{mutation.error && !isEditing ? (
|
||||||
|
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||||
|
{mutation.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.trailing}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleOpen}
|
||||||
|
disabled={!isEnabled || mutation.isPending}
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
|
||||||
|
testID="host-page-restart-recovery-edit"
|
||||||
|
>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.edit")}
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
value={isEnabled}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
testID="host-page-restart-recovery-switch"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<ResumePromptSheet
|
||||||
|
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
|
||||||
|
isSaving={mutation.isPending}
|
||||||
|
error={mutation.error?.message ?? null}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumePromptSheet({
|
||||||
|
initialPrompt,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
initialPrompt: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onSave: (prompt: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [draft, setDraft] = useState(initialPrompt);
|
||||||
|
const header = useMemo<SheetHeader>(
|
||||||
|
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
header={header}
|
||||||
|
visible
|
||||||
|
onClose={onClose}
|
||||||
|
testID="host-page-restart-recovery-sheet"
|
||||||
|
desktopMaxWidth={560}
|
||||||
|
>
|
||||||
|
<SettingsTextAreaCard
|
||||||
|
testID="host-page-restart-recovery-prompt-input"
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
placeholder={DEFAULT_RESUME_PROMPT}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.sheetActions}>
|
||||||
|
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
|
||||||
|
{t("common.actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
testID="host-page-restart-recovery-save"
|
||||||
|
>
|
||||||
|
{t("common.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
sheetActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -371,6 +371,22 @@ describe("viewed timeline persistence", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preparing an already painted cache again keeps history out of the live head", async () => {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||||
|
const replica = createTimelineReplica({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
|
||||||
|
prepareAgent: async () => undefined,
|
||||||
|
});
|
||||||
|
await replica.prepare(AGENT_ID);
|
||||||
|
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
|
||||||
|
expect(painted).toEqual(cachedTimeline().items);
|
||||||
|
await replica.prepare(AGENT_ID);
|
||||||
|
const session = useSessionStore.getState().sessions[SERVER_ID]!;
|
||||||
|
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
|
||||||
|
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
|
||||||
|
});
|
||||||
|
|
||||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||||
let release!: (value: CachedTimeline) => void;
|
let release!: (value: CachedTimeline) => void;
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica {
|
|||||||
async prepare(agentId: string): Promise<void> {
|
async prepare(agentId: string): Promise<void> {
|
||||||
const existing = this.preparations.get(agentId);
|
const existing = this.preparations.get(agentId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
const timeline = selectAgentTimelineState(
|
||||||
|
useSessionStore.getState().sessions[this.serverId],
|
||||||
|
agentId,
|
||||||
|
);
|
||||||
|
// A route and the visible-chat owner can both prepare before catch-up finishes.
|
||||||
|
// Repainting that same cache would treat its history as a live head.
|
||||||
|
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const preparation = this.load(agentId).finally(() => {
|
const preparation = this.load(agentId).finally(() => {
|
||||||
if (this.preparations.get(agentId) === preparation) {
|
if (this.preparations.get(agentId) === preparation) {
|
||||||
this.preparations.delete(agentId);
|
this.preparations.delete(agentId);
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
|
||||||
|
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||||
|
const createPluginClientBundleStore = () => ({
|
||||||
|
async load() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async replace() {},
|
||||||
|
});
|
||||||
|
const { client } = await connectToDaemon(
|
||||||
|
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
|
||||||
|
{ createPluginClientBundleStore },
|
||||||
|
probe.deps,
|
||||||
|
);
|
||||||
|
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("encodes the local socket target into the client config", async () => {
|
it("encodes the local socket target into the client config", async () => {
|
||||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||||
const result = await connectToDaemon(
|
const result = await connectToDaemon(
|
||||||
|
|||||||
@@ -132,12 +132,13 @@ function resolveConnectionCredentials(
|
|||||||
export async function buildClientConfig(
|
export async function buildClientConfig(
|
||||||
connection: HostConnection,
|
connection: HostConnection,
|
||||||
serverId?: string,
|
serverId?: string,
|
||||||
options?: {
|
options: {
|
||||||
capabilities?: DaemonClientConfig["capabilities"];
|
capabilities?: DaemonClientConfig["capabilities"];
|
||||||
trace?: DaemonClientConfig["trace"];
|
trace?: DaemonClientConfig["trace"];
|
||||||
|
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||||
password?: string;
|
password?: string;
|
||||||
localCredential?: DaemonClientConfig["localCredential"];
|
localCredential?: DaemonClientConfig["localCredential"];
|
||||||
},
|
} = {},
|
||||||
deps: Pick<
|
deps: Pick<
|
||||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||||
| "getClientId"
|
| "getClientId"
|
||||||
@@ -157,8 +158,9 @@ export async function buildClientConfig(
|
|||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
...resolveConnectionCredentials(connection, options),
|
...resolveConnectionCredentials(connection, options),
|
||||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||||
...(options?.trace ? { trace: options.trace } : {}),
|
...(options.trace ? { trace: options.trace } : {}),
|
||||||
|
createPluginClientBundleStore: options.createPluginClientBundleStore,
|
||||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||||
desktopTransportFactory
|
desktopTransportFactory
|
||||||
? { transportFactory: desktopTransportFactory }
|
? { transportFactory: desktopTransportFactory }
|
||||||
@@ -288,6 +290,7 @@ interface ProbeOptions {
|
|||||||
localCredential?: DaemonClientConfig["localCredential"];
|
localCredential?: DaemonClientConfig["localCredential"];
|
||||||
capabilities?: DaemonClientConfig["capabilities"];
|
capabilities?: DaemonClientConfig["capabilities"];
|
||||||
trace?: DaemonClientConfig["trace"];
|
trace?: DaemonClientConfig["trace"];
|
||||||
|
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ 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";
|
||||||
@@ -20,13 +23,30 @@ 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;
|
||||||
@@ -54,7 +74,10 @@ 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, { timeout: remaining() });
|
await client.restartServer("cli_restart", undefined, {
|
||||||
|
timeout: remaining(),
|
||||||
|
killTerminals,
|
||||||
|
});
|
||||||
acknowledged = true;
|
acknowledged = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isReconnectFailure(error)) throw error;
|
if (!isReconnectFailure(error)) throw error;
|
||||||
@@ -77,6 +100,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
|
|||||||
const status = await replacement.getDaemonStatus({ timeout: remaining() });
|
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,6 +103,7 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { hostname, tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Command } from "commander";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { createStopCommandRunner, type StopCommandDeps } from "./stop.js";
|
||||||
|
|
||||||
|
// The daemon the gate is checked against: a connection whose server_info may or may not
|
||||||
|
// advertise terminalsSurviveRestart, and that records any shutdown it is asked for.
|
||||||
|
function createFakeDaemon(features: { terminalsSurviveRestart?: boolean } = {}) {
|
||||||
|
const shutdownRequests: Array<{ keepTerminals?: boolean }> = [];
|
||||||
|
const deps: StopCommandDeps = {
|
||||||
|
connectToDaemon: async () => ({
|
||||||
|
getLastServerInfoMessage: () => ({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "fake-daemon",
|
||||||
|
version: "0.11.0",
|
||||||
|
features,
|
||||||
|
}),
|
||||||
|
shutdownServer: async (options) => {
|
||||||
|
shutdownRequests.push({ keepTerminals: options?.keepTerminals });
|
||||||
|
return { status: "shutdown_requested", clientId: "fake-client", requestId: "fake-request" };
|
||||||
|
},
|
||||||
|
close: async () => undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { deps, shutdownRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const cleanup of cleanups.splice(0)) cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stop(options: Record<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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||