Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8938fde3c7 chore(release): cut 0.15.0 2026-11-03 06:08:53 +01:00
Mohamed Boudra fd1cf594b1 docs(changelog): prepare 0.15.0 2026-11-03 05:34:45 +01:00
Mohamed Boudra 6c2a4e7ca6 Keep reconnecting and updating status visible in native chats (#8568)
* fix(app): keep chat status toasts visible below floating headers

Compact native chats extend beneath the floating header. Panel toasts need measured header clearance and a layer above its fade; placing them below the fade leaves an excessive gap. Keep dock overlays outside keyboard translation so opening the keyboard cannot move them offscreen.

* test(app): verify rendered chat status spacing on native devices

Measure the real header and toast bounds with the software keyboard closed and open. The former arithmetic helper test passed even when the viewport stopped consuming header clearance; the native regression fails against that broken production wiring.

* test(app): exercise toast placement through host disconnection

Observe the public reconnecting state with host connectivity interrupted externally. Keep native bounds and software keyboard assertions while removing runtime evaluation and private timeline-owner mutations.

(cherry picked from commit b92a34ab3d)
2026-11-03 05:33:51 +01:00
paseo-bot[bot] c06ea2d2e1 fix(relay): keep local E2E tests independent of production upstream (#8514)
Wrangler local mode retains deployment vars, so relay readiness probes were forwarded to an unavailable upstream instead of the local Durable Object. Clear that binding only in the E2E launcher.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit b280ca5355)
2026-11-03 04:51:13 +01:00
Mohamed Boudra a53ac6c802 Make interrupted-agent recovery opt-in (#8619)
* fix(agents): require opt-in for restart recovery

Keep automatic interrupted-turn resumption off during further dogfooding. Preserve explicit opt-in and custom resume prompts.

* test(settings): verify restart recovery opt-in persists

* docs(agents): describe restart recovery opt-in

(cherry picked from commit 6f6712be62)
2026-11-03 04:21:27 +01:00
Mohamed Boudra 852fc2cd07 fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.

(cherry picked from commit 891e62309c)
2026-11-03 03:14:27 +01:00
Mohamed Boudra 8bd38fb236 chore(release): cut 0.15.0-beta.1 2026-11-01 06:19:33 +01:00
Mohamed Boudra 7e5ad53491 docs(changelog): prepare 0.15.0-beta.1 2026-11-01 06:11:09 +01:00
paseo-ai[bot] a529578cd1 fix: update lockfile signatures and Nix hash [skip ci]
(cherry picked from commit b2666a0aec)
2026-11-01 06:11:09 +01:00
118 changed files with 2084 additions and 6432 deletions
+26
View File
@@ -135,6 +135,32 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag
shell: bash
run: |
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
@@ -13,7 +13,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
## 0.14.0 - 2026-10-29
+5 -4
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
## 0.15.0 - 2026-11-03
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
@@ -13,7 +13,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 新增可选设置,在主机重启后于原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
@@ -64,6 +64,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
@@ -74,7 +75,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- Agent 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
## 0.14.0 - 2026-10-29
+4 -3
View File
@@ -14,9 +14,10 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl
## 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.
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Customize
the prompt with `agents.resumePrompt` or the same settings page.
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
+1 -4
View File
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+3 -3
View File
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
@@ -396,7 +396,7 @@ 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
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#hosts).
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Added
- Insert known agent commands from replies into the Composer draft
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
npm exec --offline -- electron-builder \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--mac \
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
sha256-YLrbUckIiyV7esu2tmiKdLL0YHsvwqcA0Xe0gSeMCC0=
+1323 -1046
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -257,12 +257,15 @@ test.describe("Settings host page", () => {
});
});
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
test("restart recovery defaults off and persists opt-in 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, false);
await setRestartRecoveryEnabled(page, true);
await page.reload();
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
@@ -0,0 +1,54 @@
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
// Leave the daemon running. This test observes the public reconnecting state.
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
assert(
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
"Pass all five arguments",
);
const scale = Number(coordinateScale);
assert(scale > 0, "Pass a positive coordinate scale");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
encoding: "utf8",
timeout: 90_000,
});
}
function capture(name) {
const snapshot = ad("snapshot", "--raw", "--json");
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
ad("screenshot", path.join(artifacts, `${name}.png`));
const nodes = JSON.parse(snapshot).data.nodes;
const header = nodes.find((node) => node.identifier === "floating-header");
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
return toast.rect.y / scale;
}
ad("wait", 'id="agent-reconnecting-toast"', "20000");
const closedTop = capture("keyboard-closed");
ad("press", `label=${JSON.stringify(composerLabel)}`);
const opened = ad("snapshot", "--raw", "--json");
const nodes = JSON.parse(opened).data.nodes;
assert(
nodes.some(
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
),
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
);
const openTop = capture("keyboard-open");
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
console.log(
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,8 +1,26 @@
import { useCallback, useState, type ReactNode } from "react";
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Portal, PortalHost } from "@gorhom/portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { EdgeFade } from "@/components/ui/edge-fade";
const OverlayClearanceContext = createContext(0);
const OverlayHostContext = createContext<string | null>(null);
/** Keep status overlays above the fade without lifting the scrolling content. */
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
const hostName = useContext(OverlayHostContext);
const active = useRetainedPanelActive();
if (!hostName) return children;
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
}
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
export function useFloatingHeaderOverlayClearance(): number {
return useContext(OverlayClearanceContext);
}
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const overlayHostName = useId();
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
const overlayClearance = floating ? headerHeight : 0;
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
<View style={styles.content}>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
<View
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
{header}
</View>
</View>
) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View>
);
}
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
flex: 1,
minHeight: 0,
},
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: {
position: "absolute",
top: 0,
@@ -1,42 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { HostPickerOption } from "./host-picker";
const actions: string[] = [];
function select() {
actions.push("select");
}
function openSettings() {
actions.push("settings");
}
it("keeps host selection and settings as separate accessible buttons", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
actions.length = 0;
act(() =>
root.render(
<HostPickerOption
serverId="host-a"
label="Host A"
showActiveConnection={false}
active={false}
onPress={select}
onOpenHostSettings={openSettings}
/>,
),
);
const host = container.querySelector('button[aria-label="Host A"]');
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
expect(host).not.toBeNull();
expect(settings).not.toBeNull();
expect(host?.contains(settings!)).toBe(false);
act(() => (settings as HTMLElement).click());
expect(actions).toEqual(["settings"]);
act(() => (host as HTMLElement).click());
expect(actions).toEqual(["settings", "select"]);
act(() => root.unmount());
container.remove();
});
@@ -8,7 +8,6 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { isWeb } from "@/constants/platform";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
@@ -90,33 +89,35 @@ export function HostPickerOption({
);
const trailingSlot = useMemo(() => {
if (!onOpenHostSettings) return undefined;
return <View style={styles.settingsSlot} />;
}, [onOpenHostSettings]);
return (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}, [
handleSettingsPress,
label,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<View style={styles.optionContainer}>
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
{onOpenHostSettings ? (
<Pressable
style={styles.settingsAction}
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</View>
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={testID}
/>
);
}
@@ -318,15 +319,6 @@ export function HostPicker({
}
const styles = StyleSheet.create((theme) => ({
optionContainer: { position: "relative" },
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
settingsAction: {
position: "absolute",
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
top: 0,
bottom: 0,
justifyContent: "center",
},
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
+8 -2
View File
@@ -1,3 +1,7 @@
import {
FloatingHeaderOverlay,
useFloatingHeaderOverlayClearance,
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
@@ -190,9 +194,11 @@ export function ToastViewport({
return clearTimer;
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
const panelClearance = useFloatingHeaderOverlayClearance();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
const content = (
<View
@@ -220,7 +226,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot());
}
return content;
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
}
function ToastCard({
@@ -179,8 +179,8 @@ export function ComposerDock({
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -214,8 +214,8 @@ export function ComposerDock({
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import ts from "typescript";
import { expect, it } from "vitest";
it("keeps dock overlays outside keyboard translation in every native layout", () => {
const source = ts.createSourceFile(
"dock.tsx",
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let surfaces = 0;
function visit(node: ts.Node) {
if (
ts.isJsxElement(node) &&
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
) {
surfaces++;
function assertStationary(child: ts.Node) {
if (ts.isJsxExpression(child))
expect(child.expression?.getText(source)).not.toBe("overlay");
ts.forEachChild(child, assertStationary);
}
ts.forEachChild(node, assertStationary);
}
ts.forEachChild(node, visit);
}
visit(source);
expect(surfaces).toBe(2);
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
import type { DesktopDaemonStatus } from "./desktop-daemon";
const runningManagedStatus: DesktopDaemonStatus = {
serverId: "desktop",
@@ -54,29 +54,6 @@ function makeDeps(overrides?: {
};
}
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("offline");
},
getSavedId: async () => "desktop",
}),
).toBe("desktop");
expect(
await resolveDesktopDaemonServerId({
getStatus: async () => runningManagedStatus,
getSavedId: async () => "old",
}),
).toBe("desktop");
});
describe("executeDaemonManagementToggle", () => {
describe("enable path (currentlyManaging: false)", () => {
it("persists the new setting then starts the daemon", async () => {
@@ -186,28 +186,6 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
}
/** Resolve the identity even when the daemon has stopped. */
export async function resolveDesktopDaemonServerId(
deps: {
getStatus(): Promise<DesktopDaemonStatus>;
getSavedId(): Promise<string | null>;
} = {
getStatus: getDesktopDaemonStatus,
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
},
): Promise<string | null> {
try {
const id = (await deps.getStatus()).serverId.trim();
if (id) return id;
} catch {
/* The persisted identity remains available if the status request fails. */
}
const id = await deps.getSavedId();
if (id === null) return null;
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
return id.trim() || null;
}
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
}
@@ -132,19 +132,12 @@ function harness() {
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
+1 -12
View File
@@ -1,9 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import {
resolveDesktopDaemonServerId,
shouldUseDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import {
@@ -30,14 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
openSettings(serverId) {
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
},
async isManagedLocalHost(serverId) {
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
},
}),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
+2 -15
View File
@@ -819,14 +819,6 @@ it("binds imported getters to each originating installation across delayed callb
hosts: {
getSnapshot: () => [],
subscribe: () => () => {},
observeHosts: () => {
calls.push(`${installation}/observe`);
return () => {};
},
addHost: async (input) => {
calls.push(`${installation}/add:${input.connection}`);
return { status: "cancelled" };
},
getPaseoClient(serverId) {
calls.push(`${installation}/${serverId}`);
return runtime.paseo;
@@ -834,12 +826,11 @@ it("binds imported getters to each originating installation across delayed callb
},
});
const source = bundle(`
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
const { getPaseoClient } = require("@getpaseo/plugin/client");
getPaseoClient("entry-host");
observeHosts(() => {});
plugin.addCommandCenterItem({
id: "read", title: "Read", icon: "Server", context: "global",
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
});
`);
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
@@ -849,13 +840,9 @@ it("binds imported getters to each originating installation across delayed callb
await second.commandCenterItems[0].onSelect({} as never);
expect(calls).toEqual([
"first/entry-host",
"first/observe",
"second/entry-host",
"second/observe",
"first/target-host",
"first/add:example:6767",
"second/target-host",
"second/add:example:6767",
]);
await first.cleanup();
await second.cleanup();
-4
View File
@@ -520,10 +520,6 @@ export function runPluginClientBundle(
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
runtime.hosts.addHost(input),
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
runtime.hosts.observeHosts(listener),
useHosts: () =>
React.useSyncExternalStore(
runtime.hosts.subscribe,
+7 -227
View File
@@ -1,66 +1,17 @@
import { describe, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginHosts, type PluginHostsSource } from "./index";
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
function registry() {
const hosts = [
{
serverId: "a",
label: "Alpha",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{
serverId: "b",
label: "Beta",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{ serverId: "a", label: "Alpha", password: "secret" },
{ serverId: "b", label: "Beta", password: "secret" },
];
const snapshots = new Map<
string,
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
>();
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
const listeners = new Set<() => void>();
const source: PluginHostsSource = {
addHost: async () => ({ status: "cancelled" }),
getHosts: () => hosts,
getSnapshot: (id) => {
const value = snapshots.get(id);
return value
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
: null;
},
renameHost: async (id, label) => {
hosts.find((candidate) => candidate.serverId === id)!.label = label;
},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async (id) => {
const index = hosts.findIndex((host) => host.serverId === id);
if (index >= 0) hosts.splice(index, 1);
for (const l of listeners) l();
},
removeConnection: async (id, connectionId) => {
const host = hosts.find((candidate) => candidate.serverId === id)!;
host.connections = host.connections.filter((c) => c.id !== connectionId);
if (!host.connections.length) await source.removeHost(id);
else for (const l of listeners) l();
},
getSnapshot: (id) => snapshots.get(id) ?? null,
subscribeAll(listener) {
listeners.add(listener);
return () => {
@@ -75,10 +26,7 @@ function registry() {
},
};
const lifetime = new AbortController();
const runtime = createPluginHosts(source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const runtime = createPluginHosts(source, lifetime.signal);
return {
hosts,
snapshots,
@@ -100,7 +48,7 @@ function connection(id: string) {
describe("plugin host access", () => {
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
const h = registry();
expect(h.runtime.getSnapshot()).toMatchObject([
expect(h.runtime.getSnapshot()).toEqual([
{ serverId: "a", label: "Alpha", status: "offline" },
{ serverId: "b", label: "Beta", status: "offline" },
]);
@@ -120,171 +68,6 @@ describe("plugin host access", () => {
expect(h.listeners.size).toBe(0);
});
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
const h = registry();
const seen: unknown[] = [];
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
const first = h.runtime.getSnapshot()[0];
function walk(value: unknown): void {
if (typeof value === "string") expect(value).not.toContain("secret");
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
expect(key.toLowerCase()).not.toBe("password");
walk(child);
}
}
walk(h.runtime.getSnapshot());
h.hosts[0].label = "New name";
h.publish();
expect(h.runtime.getSnapshot()[0]).toBe(first);
expect(first.label).toBe("New name");
expect(seen).toHaveLength(2);
h.lifetime.abort();
h.publish();
expect(seen).toHaveLength(2);
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
stop();
});
it("redacts saved passwords from mutation errors", async () => {
const h = registry();
h.source.renameHost = async () => {
throw new Error("Failed for secret");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("isolates a throwing observer from other observers and registry mutations", async () => {
const h = registry();
let calls = 0;
h.runtime.observeHosts(() => {
if (calls++ > 0) throw new Error("plugin listener failed");
});
let delivered = 0;
h.runtime.observeHosts(() => {
delivered++;
});
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
expect(delivered).toBe(2);
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
h.lifetime.abort();
});
it("refuses to remove the last connection of the desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
const host = runtime.getSnapshot()[0];
await host.removeConnection("one");
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
expect(host.connections).toHaveLength(1);
lifetime.abort();
h.lifetime.abort();
});
it("returns the projected handle after adding a host", async () => {
const h = registry();
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
expect(result.status).toBe("saved");
if (result.status !== "saved") throw new Error("expected saved host");
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
expect(JSON.stringify(result)).not.toContain("secret");
h.lifetime.abort();
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
"Plugin has stopped",
);
});
it("passes cancellation and authentication results without exposing credentials", async () => {
const h = registry();
for (const result of [
{ status: "cancelled" as const },
{ status: "password_required" as const, reason: "incorrect_password" as const },
]) {
h.source.addHost = async () => result;
const returned = await h.runtime.addHost({
connection: "tcp://example:6767?password=secret",
});
expect(returned).toEqual(result);
for (const [key, value] of Object.entries(returned)) {
expect(key).not.toBe("password");
expect(value).not.toBe("secret");
}
}
h.lifetime.abort();
});
it("redacts encoded saved passwords in mutation errors", async () => {
const h = registry();
h.hosts[0].password = "two words";
h.source.renameHost = async () => {
throw new Error("failed for two%20words");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
"two%20words",
);
h.lifetime.abort();
});
it("never exposes supplied passwords in add errors", async () => {
const h = registry();
h.source.addHost = async () => {
throw new Error("secret was rejected");
};
await expect(
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("removes a connection and then the last host connection", async () => {
const h = registry();
const host = h.runtime.getSnapshot()[0];
await host.removeConnection("one");
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
await host.removeConnection("two");
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
h.lifetime.abort();
});
it("rejects removal of the managed desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("protects the desktop host after its daemon has stopped", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) =>
id ===
(await resolveDesktopDaemonServerId({
getStatus: async () => {
throw new Error("daemon stopped");
},
getSavedId: async () => "a",
})),
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("rejects unknown and disconnected targets without falling through to another host", () => {
const h = registry();
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
@@ -299,10 +82,7 @@ describe("plugin host access", () => {
h.snapshots.set("b", { connectionStatus: "online", client });
h.publish();
const otherLifetime = new AbortController();
const other = createPluginHosts(h.source, otherLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const other = createPluginHosts(h.source, otherLifetime.signal);
const api = h.runtime.getPaseoClient("b");
expect(api).toBe(h.runtime.getPaseoClient("b"));
expect(api).not.toBe(other.getPaseoClient("b"));
+17 -319
View File
@@ -1,288 +1,31 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PluginHost,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "@getpaseo/plugin/client";
import type { HostProfile } from "@/types/host-connection";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
parseHostPort,
serializeConnectionUri,
serializeRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
import {
DEFAULT_SSH_DAEMON_PORT,
serializeSshTransportUri,
} from "@getpaseo/protocol/ssh-transport";
import type { PluginHostSummary } from "@getpaseo/plugin/client";
type HostSnapshot = Pick<
HostRuntimeSnapshot,
| "connectionStatus"
| "client"
| "activeConnectionId"
| "lastError"
| "authFailureReason"
| "lastOnlineAt"
>;
export interface PluginHostsSource {
addHost(
input: PluginHostAddInput,
signal?: AbortSignal,
): Promise<
| { status: "saved"; serverId: string }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
>;
getHosts(): readonly HostProfile[];
getSnapshot(serverId: string): HostSnapshot | null;
getHosts(): readonly { serverId: string; label: string }[];
getSnapshot(serverId: string): {
connectionStatus: PluginHostSummary["status"];
client: DaemonClient | null;
} | null;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
renameHost(serverId: string, label: string): Promise<void>;
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
setHostBadgeDisplay(
serverId: string,
badge: PluginHost["appearance"]["badgeDisplay"],
): Promise<void>;
removeHost(serverId: string): Promise<void>;
removeConnection(serverId: string, connectionId: string): Promise<void>;
}
function redactSecret(message: string, secret: string | undefined): string {
if (!secret) return message;
for (const value of [
secret,
encodeURIComponent(secret),
new URLSearchParams({ password: secret }).toString().slice(9),
]) {
message = message.split(value).join("[redacted]");
}
return message;
}
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
switch (connection.type) {
case "directTcp":
return {
id: connection.id,
type: connection.type,
endpoint: connection.endpoint,
useTls: connection.useTls ?? false,
uri: serializeConnectionUri({
...parseHostPort(connection.endpoint),
useTls: connection.useTls ?? false,
}),
};
case "directSocket":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("unix", connection.path),
};
case "directPipe":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("pipe", connection.path),
};
case "remoteSsh":
return {
id: connection.id,
type: connection.type,
host: connection.host,
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
uri: serializeSshTransportUri({
host: connection.host,
sshPort: connection.sshPort,
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
}),
};
case "relay": {
const useTls =
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
return {
id: connection.id,
type: connection.type,
relayEndpoint: connection.relayEndpoint,
useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
uri: serializeRelayConnectionUri({
offer: {
v: 2,
serverId: profile.serverId,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
relay: { endpoint: connection.relayEndpoint, useTls },
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
},
}),
};
}
case "routed":
return {
id: connection.id,
type: connection.type,
originServerId: connection.originServerId,
uri: null,
};
}
});
const error = snapshot?.lastError ?? null;
return {
serverId: profile.serverId,
label: profile.label,
status: snapshot?.connectionStatus ?? "offline",
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
connections,
activeConnectionId: snapshot?.activeConnectionId ?? null,
error: error ? redactSecret(error, profile.password) : null,
authFailureReason: snapshot?.authFailureReason ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
};
}
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
export function createPluginHosts(
source: PluginHostsSource,
signal: AbortSignal,
options: {
openSettings(serverId: string): void;
isManagedLocalHost(serverId: string): Promise<boolean>;
},
) {
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
const clients = new Map<
string,
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginHost[] = [];
let serialized = "";
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
function assertHost(id: string) {
if (signal.aborted) throw new Error("Plugin has stopped");
if (!source.getHosts().some((host) => host.serverId === id))
throw new Error(`Unknown Paseo host: ${id}`);
}
async function perform(id: string, action: () => Promise<void>): Promise<void> {
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
try {
await action();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A cause would expose the original error, including any password.
// oxlint-disable-next-line preserve-caught-error
throw new Error(redactSecret(message, secret));
}
}
function readHosts(): { hosts: PluginHost[]; key: string } {
const projected = source
.getHosts()
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
const ids = new Set(projected.map((host) => host.serverId));
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
const hosts = projected.map((data) => {
let entry = entries.get(data.serverId);
if (!entry) {
const id = data.serverId;
const read = () => entry!.data;
const handle: PluginHost = {
get serverId() {
return read().serverId;
},
get label() {
return read().label;
},
get status() {
return read().status;
},
get appearance() {
return read().appearance;
},
get connections() {
return read().connections;
},
get activeConnectionId() {
return read().activeConnectionId;
},
get error() {
return read().error;
},
get authFailureReason() {
return read().authFailureReason;
},
get lastOnlineAt() {
return read().lastOnlineAt;
},
get createdAt() {
return read().createdAt;
},
get updatedAt() {
return read().updatedAt;
},
async update(patch: PluginHostPatch) {
await perform(id, async () => {
assertHost(id);
if (patch.label !== undefined) await source.renameHost(id, patch.label);
if (patch.appearance?.color !== undefined) {
assertHost(id);
await source.setHostColor(id, patch.appearance.color);
}
if (patch.appearance?.badgeDisplay !== undefined) {
assertHost(id);
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
}
});
},
async remove() {
await perform(id, async () => {
assertHost(id);
if (await options.isManagedLocalHost(id))
throw new Error("Cannot remove the desktop daemon host");
assertHost(id);
await source.removeHost(id);
});
},
async removeConnection(connectionId) {
await perform(id, async () => {
assertHost(id);
const isManaged = await options.isManagedLocalHost(id);
assertHost(id);
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
if (
isManaged &&
host.connections.length === 1 &&
host.connections[0].id === connectionId
) {
throw new Error("Cannot remove the desktop daemon host");
}
await source.removeConnection(id, connectionId);
});
},
openSettings() {
assertHost(id);
options.openSettings(id);
},
};
entry = { data, handle };
entries.set(id, entry);
} else entry.data = data;
return entry.handle;
});
return { hosts, key: JSON.stringify(projected) };
let snapshot: readonly PluginHostSummary[] = [];
function readHosts(): readonly PluginHostSummary[] {
return source.getHosts().map(({ serverId, label }) => ({
serverId,
label,
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
}));
}
function resolve(serverId: string): DaemonClient {
if (signal.aborted) throw new Error("Plugin has stopped");
@@ -306,16 +49,11 @@ export function createPluginHosts(
}
}
const next = readHosts();
if (serialized === next.key) return;
serialized = next.key;
snapshot = next.hosts;
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
snapshot = next;
for (const listener of listeners) listener();
}
{
const initial = readHosts();
snapshot = initial.hosts;
serialized = initial.key;
}
snapshot = readHosts();
const unsubscribeRuntime = source.subscribeAll(refresh);
const unsubscribeHosts = source.subscribeHostList(refresh);
function stop() {
@@ -329,46 +67,6 @@ export function createPluginHosts(
else signal.addEventListener("abort", stop, { once: true });
return {
getSnapshot: () => snapshot,
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
if (signal.aborted) throw new Error("Plugin has stopped");
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
try {
result = await source.addHost(input, signal);
} catch (error) {
let secret: string | undefined;
try {
secret = parseHostTarget(input.connection).password;
} catch {
/* Invalid input has no parsed password. */
}
const message = redactSecret(
error instanceof Error ? error.message : String(error),
secret,
);
// The original error can contain the supplied credential.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
if (result.status !== "saved") return result;
const host = snapshot.find((item) => item.serverId === result.serverId);
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
return { status: "saved", host };
},
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
if (signal.aborted) throw new Error("Plugin has stopped");
const notify = () => {
try {
listener(snapshot);
} catch (error) {
console.error("[Plugins] Host observer failed", error);
}
};
listeners.add(notify);
notify();
return () => {
listeners.delete(notify);
};
},
subscribe(listener: () => void) {
if (signal.aborted) return () => {};
listeners.add(listener);
+1 -5
View File
@@ -1,4 +1,3 @@
import { buildSettingsHostRoute } from "@/utils/host-routes";
import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
@@ -10,7 +9,7 @@ import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: {
serverId: string;
workspaceId: string | null;
}): PluginNavigation & { openHostSettings(serverId: string): void } {
}): PluginNavigation {
const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
@@ -21,9 +20,6 @@ export function createPluginNavigation(input: {
return { mode: "pane" as const, paneId };
}
return {
openHostSettings(hostId) {
router.push(buildSettingsHostRoute(hostId));
},
openSettings(pluginId, screenId) {
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
},
@@ -43,19 +43,12 @@ function registry(
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
@@ -18,19 +18,12 @@ function registry(version: string) {
return {
hosts: createPluginHosts(
{
addHost: async () => ({ status: "cancelled" }),
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
+10 -24
View File
@@ -55,38 +55,24 @@ export class HostConfirmations {
}
/** Resolves true without asking when the link matches a saved host exactly. */
confirmLink(
offer: ConnectionOffer,
savedHosts: HostProfile[],
signal?: AbortSignal,
): Promise<boolean> {
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
if (signal?.aborted) return Promise.resolve(false);
return this.ask(
{
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
},
signal,
);
return this.ask({
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
});
}
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false);
return new Promise<boolean>((resolve) => {
const cancel = () => this.answer(request.id, false);
const finish = (approved: boolean) => {
signal?.removeEventListener("abort", cancel);
resolve(approved);
};
this.pending = { request, resolve: finish };
signal?.addEventListener("abort", cancel, { once: true });
this.pending = { request, resolve };
this.emit();
});
}
+1 -294
View File
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
@@ -33,7 +33,6 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import type { HostConfirmationRequest } from "./host-confirmation";
import { createPluginHosts } from "@/plugins/hosts";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
@@ -4779,295 +4778,3 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getHosts()).toHaveLength(0);
});
});
describe("adding hosts from plugin connection targets", () => {
it("does not save an unauthenticated target and retries with the password", async () => {
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
const requests = answerHostConfirmations(store, true);
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
status: "password_required",
reason: "password_required",
});
expect(store.getHosts()).toHaveLength(0);
expect(
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
).toEqual({ status: "saved", serverId: "srv_direct" });
expect(store.getHosts()[0]).toMatchObject({
label: "Build",
password: "secret",
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
});
expect(requests).toHaveLength(0);
await waitForHostOnline(store, "srv_direct");
store.syncHosts([]);
});
it("persists the supplied password and reconnects after the app restarts", async () => {
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
const first = createPairingStore({
storage,
password: "secret",
serverIdForHost: () => "srv_direct",
});
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
expect(result.status).toBe("saved");
expect(JSON.stringify(result)).not.toContain("secret");
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
expect(persisted[0].password).toBe("secret");
lifetime.abort();
first.syncHosts([]);
const attempts: string[] = [];
const restarted = createPairingStore({
storage,
password: "secret",
connectAttempts: attempts,
serverIdForHost: () => "srv_direct",
});
await restarted.boot();
await waitForHostOnline(restarted, "srv_direct");
expect(attempts).toContain("direct:example.com:6767");
expect(restarted.getHosts()[0].password).toBe("secret");
const restartedLifetime = new AbortController();
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
restartedLifetime.abort();
restarted.syncHosts([]);
});
it("asks before saving a relay target and respects rejection", async () => {
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
const rejected = createPairingStore();
const requests = answerHostConfirmations(rejected, false);
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
expect(requests).toHaveLength(1);
expect(rejected.getHosts()).toHaveLength(0);
const accepted = createPairingStore();
const acceptedRequests = answerHostConfirmations(accepted, true);
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
status: "saved",
serverId: "srv_offer",
});
expect(acceptedRequests).toHaveLength(1);
expect(accepted.getHosts()[0]).toMatchObject({
label: "Relay",
connections: [{ type: "relay" }],
});
accepted.syncHosts([]);
});
it.each([
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
["unix:///tmp/paseo.sock", "directSocket"],
["pipe://mypipe", "directPipe"],
["wss://example.com/ws", "directTcp"],
])("probes and saves %s", async (connection, type) => {
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
expect(await store.addHost({ connection })).toEqual({
status: "saved",
serverId: "srv_target",
});
expect(store.getHosts()[0].connections[0].type).toBe(type);
store.syncHosts([]);
});
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
const store = createPairingStore({
serverIdForHost: (host) => {
const connection = host.connections[0];
return connection?.type === "directTcp" ? connection.endpoint : "";
},
});
await store.addHost({ connection: "first:6767", label: "First" });
await store.addHost({ connection: "second:6767", label: "Second" });
await waitForHostOnline(store, "first:6767");
await waitForHostOnline(store, "second:6767");
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
expect(
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
).toEqual([
["First", "online", "tcp://first:6767"],
["Second", "online", "tcp://second:6767"],
]);
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
expect(
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
"first:6767",
),
).toMatchObject({ label: "Build", color: "sky" });
lifetime.abort();
store.syncHosts([]);
});
it.each([
"tcp://example.com:6767?ssl=true",
"ssh://deploy@build:2222?daemonPort=7777",
"unix:///tmp/paseo.sock",
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
])("recreates the projected connection on another device: %s", async (connection) => {
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(first, true);
const result = await first.addHost({ connection });
if (result.status !== "saved") throw new Error("expected saved host");
const lifetime = new AbortController();
const plugin = createPluginHosts(first, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
?.connections[0].uri;
if (!uri) throw new Error("expected connection URI");
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
answerHostConfirmations(second, true);
expect(await second.addHost({ connection: uri })).toEqual({
status: "saved",
serverId: result.serverId,
});
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
lifetime.abort();
first.syncHosts([]);
second.syncHosts([]);
});
it("does not save an in-flight addition after its plugin stops", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.resolve({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: "srv",
hostname: null,
});
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
const started = createDeferred<void>();
const probe = createDeferred<{
client: DaemonClient;
serverId: string;
hostname: string | null;
}>();
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
started.resolve(undefined);
return probe.promise;
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const adding = plugin.addHost({ connection: "example.com:6767" });
await started.promise;
lifetime.abort();
probe.reject(new DaemonAuthenticationError("password_required"));
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("dismisses an unanswered confirmation when the plugin stops", async () => {
const store = createPairingStore();
const lifetime = new AbortController();
const plugin = createPluginHosts(store, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const pending = createDeferred<void>();
const unsubscribe = store.subscribeHostConfirmation(() => {
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
});
const adding = plugin.addHost({
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
});
await pending.promise;
lifetime.abort();
await expect(adding).rejects.toThrow("Plugin has stopped");
expect(store.getPendingHostConfirmation()).toBeNull();
expect(store.getHosts()).toHaveLength(0);
unsubscribe();
store.syncHosts([]);
});
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
let failure: "auth" | "network" = "auth";
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
throw new Error("connection failed for secret");
},
getClientId: async () => "test",
readInitialConnectionHint: () => null,
},
});
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
status: "password_required",
reason: "incorrect_password",
});
expect(store.getHosts()).toHaveLength(0);
failure = "network";
await expect(
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
).rejects.not.toThrow("secret");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
it("rejects websocket paths and query options the app cannot represent", async () => {
const store = createPairingStore();
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
"unsupported",
);
await expect(
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
).rejects.toThrow("unsupported");
expect(store.getHosts()).toHaveLength(0);
store.syncHosts([]);
});
});
+5 -123
View File
@@ -3,7 +3,6 @@ import { watchdog } from "@/watchdog";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -1613,48 +1612,6 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"];
}
function directHostTarget(target: HostTarget, input: string): HostConnection {
let connection: HostConnection;
if (target.kind === "ssh") {
connection = createRemoteSshHostConnection(target.ssh);
} else {
if (/^wss?:\/\//.test(input.trim())) {
const url = new URL(target.url);
if (url.pathname !== "/ws" || url.search)
throw new Error("WebSocket path or query is unsupported by the app");
}
switch (target.connection.type) {
case "directTcp": {
const endpoint = normalizeHostPort(target.connection.endpoint);
connection = {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: target.connection.useTls,
};
break;
}
case "directSocket":
connection = {
id: `socket:${target.connection.path}`,
type: "directSocket",
path: target.connection.path,
};
break;
case "directPipe":
connection = {
id: `pipe:${target.connection.path}`,
type: "directPipe",
path: target.connection.path,
};
break;
case "relay":
throw new Error("Unexpected relay target");
}
}
return connection;
}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private hostObservations = new Map<
@@ -2007,66 +1964,6 @@ export class HostRuntimeStore {
);
}
/** Add a saved host using the shared connection grammar. */
async addHost(
input: {
connection: string;
label?: string;
},
signal?: AbortSignal,
): Promise<
| { status: "saved"; serverId: string }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" }
> {
const target = parseHostTarget(input.connection);
await this.loadRegistry();
try {
if (signal?.aborted) throw new Error("Plugin has stopped");
if (target.kind === "socket" && target.connection.type === "relay") {
const approved = await this.confirmLink(target.connection.offer, signal);
if (signal?.aborted) throw new Error("Plugin has stopped");
if (!approved) return { status: "cancelled" };
const result = await this.probeAndUpsertOffer(
target.connection.offer,
target.password,
input.label,
signal,
);
return { status: "saved", serverId: result.serverId };
}
const connection = directHostTarget(target, input.connection);
const result = await this.probeAndUpsertConnection({
connection,
password: target.password,
label: input.label,
signal,
});
return { status: "saved", serverId: result.serverId };
} catch (error) {
if (signal?.aborted) {
// The failed probe can include a credential; do not attach it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error("Plugin has stopped");
}
const reason = getConnectionAuthFailureReason(error);
if (reason) return { status: "password_required", reason };
let message = error instanceof Error ? error.message : String(error);
if (target.password) {
for (const value of [
target.password,
encodeURIComponent(target.password),
new URLSearchParams({ password: target.password }).toString().slice(9),
]) {
if (value) message = message.split(value).join("[redacted]");
}
}
// The original error can contain a credential; do not expose it as a cause.
// oxlint-disable-next-line preserve-caught-error
throw new Error(message);
}
}
async upsertDirectConnection(input: {
serverId: string;
endpoint: string;
@@ -2096,7 +1993,6 @@ export class HostRuntimeStore {
password?: string;
label?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
if (input.connection.type === "relay") {
throw new Error("Cannot probe a relay connection without a server id.");
@@ -2117,10 +2013,6 @@ export class HostRuntimeStore {
connection: input.connection,
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
});
if (input.signal?.aborted) {
await client.close().catch(() => undefined);
throw new Error("Plugin has stopped");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.label ?? hostname ?? undefined,
@@ -2170,9 +2062,9 @@ export class HostRuntimeStore {
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key, relay, or
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
await this.loadRegistry();
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
return this.hostConfirmations.confirmLink(offer, this.hosts);
}
getPendingHostConfirmation(): HostConfirmationRequest | null {
@@ -2252,8 +2144,6 @@ export class HostRuntimeStore {
private async probeAndUpsertOffer(
offer: ConnectionOffer,
password: string | undefined,
label?: string,
signal?: AbortSignal,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = {
@@ -2269,12 +2159,7 @@ export class HostRuntimeStore {
};
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
await client.close().catch(() => undefined);
if (signal?.aborted) throw new Error("Plugin has stopped");
const profile = await this.upsertConnectionFromOffer(
offer,
label ?? hostname ?? undefined,
password,
);
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
return { profile, serverId: offer.serverId, hostname };
}
@@ -2336,10 +2221,7 @@ export class HostRuntimeStore {
}));
}
async setHostBadgeDisplay(
serverId: string,
badgeDisplay: HostBadgeDisplay | null,
): Promise<void> {
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
appearance: { ...host.appearance, badgeDisplay },
@@ -3152,7 +3034,7 @@ export interface HostMutations {
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
}
@@ -59,7 +59,7 @@ export function RestartRecoveryCard({ serverId }: { serverId: string }) {
if (!isConnected || !isSupported || !config) return null;
const isEnabled = config.resumeOnStartup !== false;
const isEnabled = config.resumeOnStartup === true;
return (
<>
@@ -717,13 +717,11 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,7 +647,6 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -656,7 +655,6 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/server": "0.15.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+3 -4
View File
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, transportFactory, ...clientConfig } = config;
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -41,7 +41,7 @@
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
@@ -152,12 +152,6 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
});
it("reads the desktop daemon identity after it has stopped", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
});
it("reports an errored daemon when the local daemon state cannot be read", async () => {
mkdirSync(mocks.paseoHome);
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
@@ -476,14 +476,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
runningUnderARM64Translation: isRunningUnderARM64Translation(),
}),
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
desktop_daemon_identity: () => {
try {
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
desktop_local_credential: async (args) => {
const instance = await readDaemonInstance(getPaseoHome());
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
-12
View File
@@ -92,18 +92,6 @@ function typeErrors(code: string): string[] {
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("plugin SDK import boundaries", () => {
it("exposes host handles and object-shaped additions from the client entry", () => {
expect(
typeErrors(`
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
const hosts: readonly PluginHost[] = useHosts();
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
void addHost({ connection: "localhost:6767", label: "Local" });
void hosts; stop();
`),
).toEqual([]);
});
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
-98
View File
@@ -1,98 +0,0 @@
import type { PluginHostSummary } from "./index.js";
export type PluginHostStatus = PluginHostSummary["status"];
export type PluginHostColor =
| "none"
| "violet"
| "sky"
| "emerald"
| "orange"
| "pink"
| "indigo"
| "teal"
| "red"
| "amber"
| "blue";
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
export interface PluginHostAppearance {
readonly color: PluginHostColor;
/** Null uses the automatic local or remote default. */
readonly badgeDisplay: PluginHostBadgeDisplay | null;
}
/** Saved connection settings without credentials. Routed connections have no URI. */
export type PluginHostConnection =
| {
readonly id: string;
readonly type: "directTcp";
readonly uri: string;
readonly endpoint: string;
readonly useTls: boolean;
}
| {
readonly id: string;
readonly type: "directSocket";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "directPipe";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "remoteSsh";
readonly uri: string;
readonly host: string;
readonly sshPort?: number;
readonly daemonPort?: number;
}
| {
readonly id: string;
readonly type: "relay";
readonly uri: string;
readonly relayEndpoint: string;
readonly useTls: boolean;
readonly daemonPublicKeyB64: string;
readonly minimumSecurityMode?: "protected";
}
| {
readonly id: string;
readonly type: "routed";
readonly uri: null;
readonly originServerId: string;
};
export interface PluginHostPatch {
label?: string;
appearance?: Partial<PluginHostAppearance>;
}
export interface PluginHost extends PluginHostSummary {
readonly appearance: PluginHostAppearance;
readonly connections: readonly PluginHostConnection[];
readonly activeConnectionId: string | null;
readonly error: string | null;
readonly authFailureReason: "password_required" | "incorrect_password" | null;
readonly lastOnlineAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
update(patch: PluginHostPatch): Promise<void>;
/** Removes the host; throws for the desktop daemon host. */
remove(): Promise<void>;
/** Removing the last connection removes the host. */
removeConnection(connectionId: string): Promise<void>;
openSettings(): void;
}
export interface PluginHostAddInput {
/** Shared host connection grammar; may contain a password for the probe. */
connection: string;
label?: string;
}
export type PluginHostAddResult =
| { status: "saved"; host: PluginHost }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" };
export declare function useHosts(): readonly PluginHost[];
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
+1 -14
View File
@@ -86,20 +86,7 @@ export interface PluginHostSummary {
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
}
/** Live configured hosts. Supplied by the app's client bundle loader. */
export declare const useHosts: typeof import("./hosts.js").useHosts;
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
export declare const addHost: typeof import("./hosts.js").addHost;
export type {
PluginHost,
PluginHostStatus,
PluginHostColor,
PluginHostBadgeDisplay,
PluginHostAppearance,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "./hosts.js";
export declare function useHosts(): readonly PluginHostSummary[];
/** Borrow an online host's API under this installation's lifetime. */
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-50
View File
@@ -1,55 +1,5 @@
import { expect, test } from "vitest";
import { parseHostTarget } from "./host-target.js";
import { serializeSshTransportUri } from "./ssh-transport.js";
import { serializeSocketHostTarget } from "./host-target.js";
import {
parseConnectionUri,
serializeConnectionUri,
serializeRelayConnectionUri,
} from "./daemon-endpoints.js";
test("SSH and local socket targets round trip through their serializers", () => {
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
if (ssh.kind !== "ssh") throw new Error("expected SSH");
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
for (const [type, path] of [
["unix", "/tmp/paseo.sock"],
["pipe", "\\\\.\\pipe\\paseo"],
] as const) {
const target = parseHostTarget(`${type}://${path}`);
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
}
});
test("TCP and relay targets round trip through credential-free serializers", () => {
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
const tcpUri = serializeConnectionUri(tcp);
expect(tcpUri).not.toContain("secret");
expect(parseHostTarget(tcpUri)).toMatchObject({
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
});
const relay = parseHostTarget(
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
);
if (relay.kind !== "socket" || relay.connection.type !== "relay")
throw new Error("expected relay");
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
expect(relayUri).not.toContain("secret");
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
});
test.each([
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
[
"tcp://example.com:6767?ssl=true",
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
],
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
])("%s exposes its structured connection", (input, connection) => {
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
});
test.each([
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
+1 -26
View File
@@ -14,23 +14,12 @@ export type HostTarget =
| {
kind: "socket";
url: string;
connection:
| { type: "directTcp"; endpoint: string; useTls: boolean }
| { type: "directSocket"; path: string }
| { type: "directPipe"; path: string }
| { type: "relay"; offer: ConnectionOffer };
socketPath?: string;
password?: string;
offer?: ConnectionOffer;
description: string;
};
/** Serialize a local socket target in the shared host grammar. */
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
if (!path || path === "~") throw new Error("Invalid socket path");
return `${type}://${path}`;
}
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
export function parseHostTarget(input: string): HostTarget {
try {
@@ -68,9 +57,6 @@ function parse(input: string): HostTarget {
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
return {
kind: "socket",
connection: pipe
? { type: "directPipe", path: socketPath }
: { type: "directSocket", path: socketPath },
socketPath,
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
@@ -81,7 +67,6 @@ function parse(input: string): HostTarget {
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls: false },
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
description: `tcp://${endpoint}`,
};
@@ -90,7 +75,6 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
const endpoint = validatedEndpoint(offer.relay.endpoint);
return {
kind: "socket",
connection: { type: "relay", offer },
url: buildRelayWebSocketUrl({
endpoint,
serverId: offer.serverId,
@@ -113,7 +97,6 @@ function tcpTarget(input: string): HostTarget {
);
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
password: parts.password,
description: `tcp://${endpoint}`,
@@ -126,15 +109,7 @@ function websocketTarget(input: string): HostTarget {
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
if (url.pathname === "/") url.pathname = "/ws";
url.searchParams.delete("password");
const useTls = url.protocol === "wss:";
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
return {
kind: "socket",
connection: { type: "directTcp", endpoint, useTls },
url: url.toString(),
password,
description: url.origin,
};
return { kind: "socket", url: url.toString(), password, description: url.origin };
}
function sshTarget(input: string): HostTarget {
-13
View File
@@ -60,19 +60,6 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
};
}
/** Serialize an SSH target without daemon credentials. */
export function serializeSshTransportUri(target: SshTransportTarget): string {
const host = validateSshHost(target.host);
const separator = host.lastIndexOf("@");
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
const hostname = host.slice(separator + 1);
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
return `ssh://${username}${authority}${port}${query}`;
}
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
const host = validateSshHost(target.host);
const daemonPort = validatePort(target.daemonPort, "Daemon port");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+3
View File
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
wranglerCliPath,
"dev",
"--local",
// Local execution still inherits production vars; exercise the local Durable Object.
"--var",
"PASEO_RELAY_UPSTREAM:",
"--ip",
"127.0.0.1",
"--port",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.0-beta.1",
"version": "0.15.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/highlight": "0.15.0-beta.1",
"@getpaseo/plugin": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/client": "0.15.0",
"@getpaseo/highlight": "0.15.0",
"@getpaseo/plugin": "0.15.0",
"@getpaseo/protocol": "0.15.0",
"@getpaseo/relay": "0.15.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -120,7 +120,6 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -166,6 +165,18 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -2872,8 +2883,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -1,190 +0,0 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
@@ -1,4 +1,3 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow();
});
test("reads the rename from a transcript too large to hold as one string", async () => {
const sessionId = "oversized-transcript";
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
// A hole stands in for the long session this used to fail on: the file only
// has to be longer than a string can be, and the rename sits at its end.
await fs.writeFile(transcript, "");
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
await fs.appendFile(
transcript,
`\n${[
userRecord({ sessionId, content: "Trace the slow query" }),
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n")}\n`,
);
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Slow query hunt");
});
test("reads the rename from behind more records than one read covers", async () => {
const sessionId = "late-records-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
// One record longer than a single read, then enough records to leave the
// rename several reads back from the end of the file.
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
...Array.from({ length: 64 }, (_, index) =>
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Scrollback review");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
});
@@ -1,48 +0,0 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -2389,12 +2389,6 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2466,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel ?? this.config.model ?? null,
model: this.lastOptionsModel,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
@@ -2537,7 +2531,6 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -4426,21 +4419,6 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4988,23 +4966,6 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -5014,12 +4975,7 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
events.push({
type: "timeline",
provider: "claude",
@@ -5041,14 +4997,6 @@ class ClaudeAgentSession implements AgentSession {
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -6905,79 +6853,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
* the current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
*/
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
let end = stats.size;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
if (title) {
return title;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6989,8 +6898,9 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6999,6 +6909,7 @@ async function readClaudeRenamedSessionTitle(input: {
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
@@ -150,15 +150,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
@@ -3485,14 +3476,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
@@ -4448,9 +4431,6 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
@@ -5056,27 +5036,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try {
await interruptRequest;
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
@@ -5117,137 +5085,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return !this.client.hasPendingRequests();
}
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
@@ -6372,11 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -6781,7 +6613,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6944,7 +6775,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
@@ -1,374 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
describe("OpenCode v2 denial reasons", () => {
test.each<{
response: AgentPermissionResponse;
decision: "reject" | "once" | "always";
message?: string;
}>([
{
response: { behavior: "deny", message: "Use the existing file instead." },
decision: "reject",
message: "Use the existing file instead.",
},
{ response: { behavior: "deny" }, decision: "reject" },
{ response: { behavior: "allow" }, decision: "once" },
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
])("preserves a permission response: $response", async ({ response, decision, message }) => {
const harness = new V2Harness();
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.respondToPermission("approval", response);
expect(replies).toEqual([
{
sessionID: "session",
requestID: "approval",
decision,
...(message === undefined ? {} : { message }),
},
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test.each([true, false])(
"explains cards superseded by a steer when clearing is %s",
async (clearPendingPermissions) => {
const harness = new V2Harness();
harness.autoComplete = false;
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const { turnId } = await session.startTurn("Run pwd");
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
await expect(
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
).resolves.toEqual({ status: "accepted" });
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
expect(replies).toEqual(
clearPendingPermissions
? [
{
sessionID: "session",
requestID: "approval",
decision: "reject",
message:
"The user answered with a message instead of approving. Their message follows.",
},
]
: [],
);
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
} finally {
await session.close();
}
},
);
});
describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
@@ -68,9 +68,6 @@ export class SessionPermissions {
sessionID: this.permissionOwners.get(requestId) ?? this.id,
requestID: requestId,
decision: permissionReply(response),
...(response.behavior === "deny" && response.message !== undefined
? { message: response.message }
: {}),
});
}
this.resolvePending(requestId, response);
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
),
clearPermissions: async () => {
for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, {
behavior: "deny",
message:
"The user answered with a message instead of approving. Their message follows.",
});
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
},
});
this.usage = new SessionUsage({
@@ -5,13 +5,11 @@ import {
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
@@ -25,7 +23,6 @@ import {
PiRpcAgentSession,
transformPiModels,
} from "./agent.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { FakePi } from "./test-utils/fake-pi.js";
import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js";
@@ -2722,7 +2719,6 @@ describe("PiRpcAgentClient", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
const cwd = path.join(root, "workspace");
const sessionsDir = path.join(root, "sessions");
mkdirSync(cwd, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
writeFileSync(
@@ -3536,320 +3532,6 @@ describe("PiRpcAgentClient", () => {
});
});
describe("PiRpcAgentClient history resume", () => {
const BUILTIN_MCP_COMMAND = {
name: "mcp",
source: "extension",
sourceInfo: { path: "builtin:mcp" },
} as const;
const PASEO_MCP_SERVERS = {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
} as const;
interface ArchivedPiSession {
handle: {
provider: string;
sessionId: string;
nativeHandle: string;
metadata: { cwd: string; model?: string };
};
sessionFile: string;
header: Record<string, unknown>;
body: string;
originalBytes: Buffer;
}
function createDeletedFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
rmSync(folder, { recursive: true, force: true });
return folder;
}
function createFileWhereFolderWas(): string {
const folder = createDeletedFolder();
writeFileSync(folder, "not a directory");
onTestFinished(() => rmSync(folder, { force: true }));
return folder;
}
function createExistingFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
return folder;
}
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
};
const body = [
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
message: { role: "user", content: "Où est le café ☕ ?" },
}),
JSON.stringify({
type: "message",
id: "entry-2",
parentId: "entry-1",
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
}),
]
.map((line) => `${line}\n`)
.join("");
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
return {
handle: {
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: sessionFile,
metadata: { cwd, ...metadata },
},
sessionFile,
header,
body,
originalBytes: readFileSync(sessionFile),
};
}
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
const content = readUtf8File(sessionFile);
const headerEnd = content.indexOf("\n");
return {
header: JSON.parse(content.slice(0, headerEnd)),
body: content.slice(headerEnd + 1),
};
}
function resumeArchived(
client: PiRpcAgentClient,
archived: ArchivedPiSession,
purpose: "history" | "interactive",
overrides: Partial<AgentSessionConfig> = {},
): Promise<AgentSession> {
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
}
function writeFakePiProcess(): {
command: [string, ...string[]];
readLaunches: () => Array<{ cwd: string; session: string | null }>;
} {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const scriptPath = path.join(dir, "fake-pi.cjs");
const launchesPath = path.join(dir, "launches.jsonl");
writeFileSync(
scriptPath,
`
const fs = require("node:fs");
const readline = require("node:readline");
const [launchesPath, ...piArgs] = process.argv.slice(2);
const sessionFlag = piArgs.indexOf("--session");
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
if (sessionFile) {
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
if (header.cwd && !fs.existsSync(header.cwd)) {
fs.writeSync(
2,
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
);
process.exit(1);
}
}
const models = [
{ provider: "openrouter", id: "a" },
{ provider: "openrouter", id: "b" },
];
const state = {
sessionId: "pi-session-1",
sessionFile,
model: models[0],
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
};
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
function handle(request) {
switch (request.type) {
case "get_state":
return state;
case "get_commands":
return { commands };
case "get_available_models":
return { models };
case "set_model":
fs.appendFileSync(
sessionFile,
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
);
state.model = models.find((model) => model.id === request.modelId);
return state.model;
default:
return {};
}
}
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
process.stdout.write(
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
);
});
`,
);
return {
command: [process.execPath, scriptPath, launchesPath],
readLaunches: () =>
readUtf8File(launchesPath)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
};
}
test.each([
["is gone", createDeletedFolder],
["is now a file", createFileWhereFolderWas],
])(
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
async (_folderState, createFolder) => {
const archived = writeArchivedSession(createFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toHaveLength(1);
const launch = pi.recordedLaunches[0]!;
expect(launch.cwd).toBe(homedir());
expect(launch.session).not.toBe(archived.sessionFile);
expect(readSessionCopy(launch.session!)).toEqual({
header: { ...archived.header, cwd: homedir() },
body: archived.body,
});
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
await session.getRuntimeInfo();
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
expect(session.describePersistence()).toMatchObject({
nativeHandle: archived.sessionFile,
metadata: { cwd: storedCwd },
});
});
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
});
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
"Pi RPC process exited with code 1",
);
expect(pi.recordedLaunches).toHaveLength(1);
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
});
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueCommands([BUILTIN_MCP_COMMAND]);
const session = await resumeArchived(createClient(pi), archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
expect(session.capabilities.supportsMcpServers).toBe(true);
});
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
`Stored session working directory does not exist: ${storedCwd}`,
);
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
const storedCwd = createExistingFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
const fakePiProcess = writeFakePiProcess();
const logger = pino({ level: "silent" });
const client = new PiRpcAgentClient({
logger,
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
});
const session = await resumeArchived(client, archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
const launches = fakePiProcess.readLaunches();
const home = realpathSync.native(homedir());
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const sessionCopy = launches[1]!.session!;
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, join, resolve as resolvePath } from "node:path";
import { join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import stripAnsi from "strip-ansi";
import { z } from "zod";
@@ -22,7 +22,6 @@ import {
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -76,13 +75,8 @@ import {
piExtensionRuntimeBridge,
type PiExtensionEventOutput,
} from "./extensions/index.js";
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
import { revertPiConversation } from "./rewind.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -251,7 +245,6 @@ interface PiRpcAgentSessionOptions {
initialState: PiSessionState;
capabilities: AgentCapabilityFlags;
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
extensionTimeoutMs?: number;
logger: Logger;
@@ -499,14 +492,13 @@ function buildResumeConfig(
function buildResumeStartInput(input: {
resumeConfig: PiResumeConfig;
processCwd: string;
sessionFile: string;
launchContext: AgentLaunchContext | undefined;
mcpConfigFile: PiTempFile | null;
paseoExtension: PiTempFile | null;
}): PiStartSessionInput {
return {
cwd: input.processCwd,
cwd: input.resumeConfig.cwd,
env: input.launchContext?.env,
session: input.sessionFile,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
@@ -626,19 +618,6 @@ function createPiMcpConfigFile(
};
}
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
const cleanup = () => rmSync(dir, { recursive: true, force: true });
const filePath = join(dir, basename(sessionFile));
try {
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
} catch (error) {
cleanup();
throw error;
}
return { path: filePath, cleanup };
}
function createPiPaseoExtensionFile({
systemPrompt,
mcpServers = {},
@@ -1253,7 +1232,6 @@ export class PiRpcAgentSession implements AgentSession {
this.capabilities = options.capabilities;
this.provider = PI_PROVIDER;
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
@@ -1284,7 +1262,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly runtimeSession: PiRuntimeSession;
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly extensionTimeoutMs: number;
@@ -1519,7 +1496,7 @@ export class PiRpcAgentSession implements AgentSession {
return {
provider: this.provider,
sessionId: this.state.sessionId,
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
nativeHandle: this.state.sessionFile,
metadata: {
cwd: this.config.cwd,
...(this.config.model ? { model: this.config.model } : {}),
@@ -2668,7 +2645,6 @@ export class PiRpcAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2677,7 +2653,6 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
const providerOptions = PiProviderOptionsSchema.parse(
resumeConfig.config.providerOptions ?? {},
@@ -2688,7 +2663,7 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env,
};
const mcp = await this.prepareMcpInjection(
processCwd,
resumeConfig.cwd,
resumeConfig.config.mcpServers,
mcpEnv,
runtime,
@@ -2701,29 +2676,20 @@ export class PiRpcAgentClient implements AgentClient {
),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
const cleanupTempFiles = () => {
for (const tempFile of tempFiles) tempFile.cleanup();
};
let sessionCopy: PiTempFile | null = null;
let runtimeSession: PiRuntimeSession;
try {
if (processCwd !== resumeConfig.cwd) {
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
tempFiles.push(sessionCopy);
}
runtimeSession = await runtime.startSession(
buildResumeStartInput({
resumeConfig,
processCwd,
sessionFile: sessionCopy?.path ?? sessionFile,
sessionFile,
launchContext,
mcpConfigFile,
paseoExtension,
}),
);
} catch (error) {
cleanupTempFiles();
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
throw error;
}
try {
@@ -2736,15 +2702,15 @@ export class PiRpcAgentClient implements AgentClient {
},
initialState,
capabilities: capabilitiesForSession(mcp !== null),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
cleanupTempFiles();
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
throw error;
}
}
@@ -1,13 +1,9 @@
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { expect, test } from "vitest";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
async function writeSession(root: string, lines: unknown[]): Promise<string> {
const sessionsDir = path.join(root, "sessions", "project");
@@ -220,97 +216,3 @@ test("Pi import config preserves thinking before a later model in large sessions
thinkingOptionId: "low",
});
});
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: path.join(root, "removed-worktree"),
};
const body = Buffer.from(
Array.from(
{ length: 2_000 },
(_, index) =>
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
).join(""),
);
expect(body.length).toBeGreaterThan(64 * 1024);
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
const copy = await readFile(targetFile);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
});
test("a Pi session copy refuses a file without a session header", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
});
const COPY_BODY = [
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join("");
const COPY_HEADER = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/removed-worktree",
};
async function copySessionContent(content: string): Promise<Buffer> {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, content);
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
return readFile(targetFile);
}
test.each([
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
["blank lines before the header", "\n\r\n", COPY_HEADER],
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
});
test("a Pi session copy reports a missing session file", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "missing.jsonl");
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
});
@@ -1,8 +1,7 @@
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type {
ImportableProviderSession,
@@ -19,7 +18,6 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
@@ -102,59 +100,6 @@ export async function listPiImportableSessions(
);
}
export async function copyPiSessionFileWithCwd(input: {
sourceFile: string;
targetFile: string;
cwd: string;
}): Promise<void> {
const found = findSessionHeaderLine(
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
);
if (!found) {
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
}
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
mode: 0o600,
});
await pipeline(
createReadStream(input.sourceFile, { start: found.bodyStart }),
createWriteStream(input.targetFile, { flags: "a" }),
);
}
function findSessionHeaderLine(
scanned: Buffer,
): { header: Record<string, unknown>; bodyStart: number } | null {
let lineStart = 0;
while (lineStart < scanned.length) {
const newline = scanned.indexOf(0x0a, lineStart);
const bodyStart = newline === -1 ? scanned.length : newline + 1;
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
if (line) {
const header = parseJsonRecord(line);
return header?.type === "session" ? { header, bodyStart } : null;
}
lineStart = bodyStart;
}
return null;
}
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
const handle = await open(filePath, "r").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Pi session file is missing: ${filePath}`);
}
throw error;
});
try {
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
const descriptor = await readPiSessionDescriptor(filePath);
if (!descriptor) return {};
@@ -1,5 +1,3 @@
import { existsSync, readFileSync } from "node:fs";
import type {
PiRuntime,
PiRuntimeLaunch,
@@ -77,12 +75,6 @@ export class FakePi implements PiRuntime {
const session = new FakePiSession(launch, this.removedModels);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
if (missingSessionCwd !== null) {
session.getStateError = new Error(
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
);
}
this.sessions.push(session);
return session;
}
@@ -109,27 +101,6 @@ export class FakePi implements PiRuntime {
}
}
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
if (!sessionFile || !existsSync(sessionFile)) {
return null;
}
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
let header: unknown;
try {
header = JSON.parse(headerLine);
} catch {
return null;
}
if (typeof header !== "object" || header === null || !("cwd" in header)) {
return null;
}
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
return null;
}
return cwd;
}
export class FakePiSession implements PiRuntimeSession {
readonly environment: Record<string, string>;
readonly prompts: Array<{ message: string; imageCount: number }> = [];
@@ -1,26 +0,0 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import type { AgentResumePurpose } from "../agent-sdk-types.js";
export async function resolveResumeProcessCwd(
cwd: string,
purpose: AgentResumePurpose | undefined,
): Promise<string> {
if (purpose !== "history" || (await isDirectory(cwd))) {
return cwd;
}
return homedir();
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
@@ -1590,7 +1590,7 @@ function createToolCatalog(
{
title: "Create agent",
description:
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const standalonePort = await findFreePort();
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
+2 -2
View File
@@ -600,7 +600,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
resumeOnStartup: config.resumeOnStartup !== false,
resumeOnStartup: config.resumeOnStartup === true,
resumePrompt: config.resumePrompt ?? "Resume working.",
appendSystemPrompt: config.appendSystemPrompt ?? "",
pluginsEnabled: config.pluginsEnabled ?? false,
@@ -1047,7 +1047,7 @@ export async function createPaseoDaemon(
const restartRecovery = createRestartRecovery({
storage: agentStorage,
settings: () => ({
enabled: daemonConfigStore.get().resumeOnStartup !== false,
enabled: daemonConfigStore.get().resumeOnStartup === true,
prompt: daemonConfigStore.get().resumePrompt ?? "Resume working.",
}),
eligible: async (record) =>
+18
View File
@@ -40,6 +40,24 @@ describe("server config", () => {
expect(blockingMs).toBeLessThan(50);
});
test.each([undefined, false, true])(
"restart recovery requires explicit opt-in (%s)",
async (enabled) => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-recovery-"));
roots.push(paseoHome);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({
version: 1,
agents: { resumeOnStartup: enabled, resumePrompt: "Continue exactly.\n" },
}),
);
const config = loadConfig(paseoHome, { env: {} });
expect(config.resumeOnStartup).toBe(enabled === true);
expect(config.resumePrompt).toBe("Continue exactly.\n");
},
);
test("records when the daemon is managed by Paseo Desktop", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
roots.push(paseoHome);
+1 -1
View File
@@ -839,7 +839,7 @@ function resolveSpeechOverrideControlledPaths(
function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) {
return {
resumeOnStartup: agents?.resumeOnStartup ?? true,
resumeOnStartup: agents?.resumeOnStartup ?? false,
resumePrompt: agents?.resumePrompt ?? "Resume working.",
};
}
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
await paseo.dispose();
});
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
const b = await daemon();
const a = await daemon();
const owner = await api(a);
@@ -210,16 +210,33 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
code: "permission_denied",
});
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
});
await directClient.connect();
const direct = createPaseoApi(directClient);
await remote.dispose();
});
test("SDK host clients preserve ordinary permission RPC errors", async () => {
const b = await daemon();
const a = await daemon();
const owner = await api(a);
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
const clients = [a, b].map((host) =>
createHostClient(`127.0.0.1:${host.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
}),
);
for (const client of clients) {
cleanup.push(() => client.close());
await client.connect();
}
const origin = createPaseoApi(clients[0]!);
const direct = createPaseoApi(clients[1]!);
const remote = await origin.hosts.client(serverId);
cleanup.push(async () => {
await remote.dispose();
await direct.dispose();
await directClient.close();
await origin.dispose();
});
const input = { source: { kind: "directory" as const, path: b.staticDir } };
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
@@ -230,7 +247,6 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
};
expect(directError.code).toBe("access_denied");
expect(remoteError.code).toBe(directError.code);
await remote.dispose();
});
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
@@ -768,211 +784,6 @@ test("old capabilities fail before execution and authentication errors recover t
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
});
async function updatableDestination() {
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
let holding = false;
const held: (() => void)[] = [];
let connections = 0;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
socket.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
if (frame.type !== "hello") return;
const reply = () =>
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_updated",
hostname: "updated",
version: "0.12.0",
...(features ? { features } : {}),
},
},
}),
);
if (holding) held.push(reply);
else reply();
});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing destination port");
return {
port: address.port,
connections: () => connections,
hold: () => {
holding = true;
},
update: () => {
features = { connectedHosts: true, hostMultiplexing: true };
holding = false;
for (const reply of held.splice(0)) reply();
},
};
}
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
let retryAt = "";
await expect
.poll(
async () => {
const row = (await paseo.hosts.list()).hosts[0];
retryAt = row?.nextRetryAt ?? "";
return row;
},
{ timeout: 5000 },
)
.toMatchObject({
status: "offline",
nextRetryAt: expect.any(String),
error: { code: "unsupported_capability" },
});
return Date.parse(retryAt);
}
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await capabilityRetry(paseo);
destination.hold();
await expect
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
code: "unsupported_capability",
});
destination.update();
expect((await online(paseo)).hosts[0]).toMatchObject({
registrationId: host.registrationId,
serverId: "srv_updated",
nextRetryAt: null,
error: null,
});
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
await expect
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
});
test("disabling or removing a host cancels its pending capability retry", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
let retryAt = await capabilityRetry(paseo);
let connections = destination.connections();
await paseo.hosts.update(host.registrationId, { enabled: false });
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
await paseo.hosts.update(host.registrationId, { enabled: true });
retryAt = await capabilityRetry(paseo);
connections = destination.connections();
await paseo.hosts.remove(host.registrationId);
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
});
import {
createDaemonChannel,
exportPublicKey,
generateKeyPair,
openDaemonSecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
const keys = generateKeyPair();
const legacyReceived: unknown[] = [];
let connections = 0;
let retryClosed = false;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
const wire: Transport = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
if (connections > 1) {
socket.on("close", () => {
retryClosed = true;
});
void createDaemonChannel(wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
return;
}
const channel = openDaemonSecureChannel(wire, {
daemonKeyPair: keys,
events: {
onmessage: (data) => {
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
void channel.then((secure) =>
secure.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_relayed",
hostname: "relayed",
version: "0.12.0",
},
},
}),
),
);
},
},
});
void channel.catch(() => {});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing relay port");
const a = await daemon();
const { paseo } = await api(a);
await paseo.hosts.add({
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
});
await capabilityRetry(paseo);
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
expect(legacyReceived).toEqual([]);
});
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
const reservation = createServer();
reservation.listen(0, "127.0.0.1");
@@ -7,12 +7,10 @@ import { z } from "zod";
import {
createHostClient,
createHostChannelTransport,
createHostTransportFactory,
parseHostTarget,
HostTransportBudget,
} from "@getpaseo/client/node";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import {
HostSummarySchema,
HostAddSchema,
@@ -237,30 +235,23 @@ export class ConnectedHosts {
if (route.registrationId === id)
route.close(code, "Host access was disabled, replaced or removed");
}
private start(
row: Saved,
resume?: { failures: number; transports: DaemonTransportFactory },
): void {
private start(row: Saved): void {
if (!row.enabled || this.closed) return;
const transports =
resume?.transports ??
createHostTransportFactory(row.connection, this.budget, {
webSocketAgent: webSocketProxyAgent,
});
let stopped = false;
let attempts = resume?.failures ?? 0;
let retry: ReturnType<typeof setTimeout> | undefined;
const client = createHostClient(row.connection, {
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
transportFactory: transports,
});
const client = createHostClient(
row.connection,
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
webSocketAgent: webSocketProxyAgent,
},
this.budget,
);
const live: Live = {
client,
accepted: false,
stop: () => {
stopped = true;
clearTimeout(retry);
void client.close();
},
};
@@ -294,7 +285,7 @@ export class ConnectedHosts {
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
throw new HostError(
"unsupported_capability",
"Update the destination daemon to use connected hosts; retrying automatically.",
"Update the destination daemon to use connected hosts.",
);
// Pin and persist under the same writer as management to prevent discovery races.
await this.mutate(async () => {
@@ -313,22 +304,16 @@ export class ConnectedHosts {
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next);
live.accepted = true;
attempts = 0;
this.changed();
});
} catch (error) {
if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline",
message: "Host connection failed",
};
if (terminalFailures.has(row.error.code)) {
row.status = "error";
row.nextRetryAt = null;
} else {
stopped = true;
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
}
row.nextRetryAt = null;
this.changed();
await client.close();
}
@@ -344,17 +329,18 @@ export class ConnectedHosts {
if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
if (state.status === "disconnected")
row.error = connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
};
else if (row.error?.code !== "unsupported_capability") row.error = null;
row.error =
state.status === "disconnected"
? (connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
})
: null;
if (row.error?.code === "authentication_failed") {
row.status = "error";
row.nextRetryAt = null;
@@ -365,18 +351,6 @@ export class ConnectedHosts {
});
void client.connect().catch(() => {});
}
private scheduleAcceptanceRetry(
row: Saved,
failures: number,
transports: DaemonTransportFactory,
): ReturnType<typeof setTimeout> {
const delay = acceptanceRetryDelayMs(failures);
row.status = "offline";
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
retry.unref();
return retry;
}
async openRoute(input: {
serverId: string;
scopeId: string;
@@ -557,20 +531,6 @@ export class ConnectedHosts {
}
}
const ACCEPTANCE_RETRY_BASE_MS = 1500;
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
function acceptanceRetryDelayMs(failures: number): number {
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
}
const terminalFailures = new Set([
"server_id_mismatch",
"self_host",
"duplicate_host",
"authentication_failed",
]);
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
if (error instanceof HostError) return { code: error.code, message: error.message };
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
@@ -88,9 +88,13 @@ test.each(["uncommitted", "base", "commit"] as const)(
60_000,
);
test.each(["single", "batch", "rename"] as const)(
"%s diff includes hunks from a repository subdirectory before and after commit",
async (shape) => {
test.each(
(["uncommitted", "base"] as const).flatMap((mode) =>
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
@@ -108,7 +112,9 @@ test.each(["single", "batch", "rename"] as const)(
}
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
git(cwd, "checkout", "-b", "feature");
if (mode === "base") {
git(cwd, "checkout", "-b", "feature");
}
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
@@ -117,38 +123,36 @@ test.each(["single", "batch", "rename"] as const)(
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
}
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
if (mode === "base") {
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
}
await expectSubdirectoryDiff("uncommitted");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
await expectSubdirectoryDiff("base");
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
@@ -1,67 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
const artifacts = path.join(process.cwd(), ".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
const mcp = new Client({ name: "mode-contract-test", version: "1" });
try {
const parent = await ctx.client.createAgent({
provider: "claude",
cwd: root,
modeId: "default",
title: "Attended parent",
});
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const { tools } = await mcp.listTools();
const create = tools.find((tool) => tool.name === "create_agent");
expect.soft(create?.description).toContain("settings.modeId");
const args = {
provider: "codex/gpt-5.4-mini",
title: "Cross-provider child",
initialPrompt: "Reply with OK",
};
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
expect(refused.isError).toBe(true);
expect.soft(refused.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
}),
]);
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
const inspected = await mcp.callTool({
name: "inspect_provider",
arguments: { provider: "codex" },
});
expect(inspected.isError).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
});
const created = await mcp.callTool({
name: "create_agent",
arguments: { ...args, settings: { modeId: "auto" } },
});
expect(created.isError).not.toBe(true);
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
const childId = (created.structuredContent as { agentId: string }).agentId;
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
} finally {
await mcp.close();
await ctx.cleanup();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -134,7 +134,7 @@ test.each([false, true])(
let client: DaemonClient | undefined;
let mcp: Client | undefined;
try {
const first = await restartFixture(root);
const first = await restartFixture(root, { resumeOnStartup: true });
processes.push(first.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
await client.connect();
@@ -182,7 +182,7 @@ test.each([false, true])(
await client.close();
client = undefined;
await stopFixture(first.child, abrupt);
const second = await restartFixture(root);
const second = await restartFixture(root, { resumeOnStartup: true });
processes.push(second.child);
await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1);
const prompts = await readPrompts(root);
@@ -222,7 +222,7 @@ test.each([false, true])(
await client.close();
client = undefined;
await stopFixture(second.child, false);
const third = await restartFixture(root);
const third = await restartFixture(root, { resumeOnStartup: true });
processes.push(third.child);
expect(await readPrompts(root)).toHaveLength(1);
expect(
@@ -243,7 +243,7 @@ test.each([false, true])(
60000,
);
test.each([false, true])(
test.each([undefined, false, true])(
"host restart recovery settings (enabled=%s)",
async (enabled) => {
const root = await mkdtemp(path.join(tmpdir(), "resume-settings-process-"));
@@ -314,7 +314,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async ()
const processes: ChildProcess[] = [];
let client: DaemonClient | undefined;
try {
const first = await restartFixture(root);
const first = await restartFixture(root, { resumeOnStartup: true });
processes.push(first.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
await client.connect();
@@ -338,7 +338,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async ()
client = undefined;
await stopFixture(first.child, false);
const second = await restartFixture(root, {}, { holdResume: true });
const second = await restartFixture(root, { resumeOnStartup: true }, { holdResume: true });
processes.push(second.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" });
await client.connect();
@@ -102,52 +102,53 @@ describe("daemon E2E", () => {
},
);
test("downloads ASCII and Unicode filenames through one workspace", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
for (const { fileName, disposition } of [
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
]) {
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
])(
"issues token over WS and downloads $fileName via HTTP",
async ({ fileName, disposition }) => {
const cwd = tmpCwd();
try {
const filePath = path.join(cwd, fileName);
const fileContents = `download test payload: ${fileName}`;
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
expect(tokenResponse.error).toBeNull();
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
},
60000,
);
test("rejects invalid token", async () => {
const response = await fetch(
@@ -1,129 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import type { FormInfo } from "@opencode/client";
import { expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
async function createQuestionHarness() {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
const runtime = new V2Harness();
runtime.autoComplete = false;
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
runtime.api.provider.list = async () => ({
location: runtime.info.location,
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
});
let form: FormInfo | null = null;
const answers: Array<Record<string, unknown>> = [];
runtime.api.session.form.list = async () => (form ? [form] : []);
runtime.api.session.form.cancel = async () => {
form = null;
runtime.finishExecution("interrupted");
};
runtime.api.session.form.reply = async ({ answer }) => {
answers.push(answer);
form = null;
runtime.finishExecution();
};
// A steer is admitted to the existing execution, rather than starting another one.
runtime.prompt = async (input) => {
runtime.prompts.push(input.text);
if (runtime.prompts.length === 1) runtime.startExecution();
};
const provider = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: runtime.runtime,
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...createTestAgentClients(), opencode: provider },
paseoHomeRoot: path.join(root, "daemon"),
staticDir: path.join(root, "static"),
cleanup: false,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const recipient = await client.createAgent({
provider: "opencode",
cwd: root,
title: "Waiting for a human answer",
});
const sender = await client.createAgent({ provider: "claude", cwd: root });
await client.sendMessage(recipient.id, "Ask which color to use");
await expect.poll(() => runtime.prompts).toHaveLength(1);
form = {
id: "color-question",
sessionID: "session",
title: "Color",
fields: [{ key: "color", title: "Color", type: "string" }],
};
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
await client.waitForAgentUpsert(recipient.id, (agent) =>
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
);
return {
client,
recipient,
sender,
answers,
async close() {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
},
};
} catch (error) {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
throw error;
}
}
test("an agent-sourced steer leaves the question pending and answerable", async () => {
const { client, recipient, sender, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Peer status check", {
sourceAgentId: sender.id,
});
// The public wait includes admitted message delivery before reporting permission status.
const waiting = await client.waitForFinish(recipient.id);
expect(waiting.status).toBe("permission");
expect(waiting.final?.pendingPermissions).toMatchObject([
{ id: "color-question", kind: "question" },
]);
await client.respondToPermission(recipient.id, "color-question", {
behavior: "allow",
updatedInput: { answers: { Color: "Blue" } },
});
await client.waitForAgentUpsert(
recipient.id,
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
);
expect(answers).toEqual([{ color: "Blue" }]);
} finally {
await close();
}
});
test("a human steer still dismisses the question", async () => {
const { client, recipient, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
const finished = await client.waitForFinish(recipient.id);
expect(finished.status).toBe("idle");
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
expect(answers).toEqual([]);
} finally {
await close();
}
});
@@ -20,7 +20,6 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import {
startNpmRegistry,
npmPluginPackages,
@@ -180,55 +179,6 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
return { runtime, started, releaseStart, starts };
}
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
const gates = new Map<string, PromiseWithResolvers<void>>();
const gate = (pluginId: string) => {
const existing = gates.get(pluginId);
if (existing) return existing;
const created = Promise.withResolvers<void>();
gates.set(pluginId, created);
return created;
};
const begun: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
getProviderRegistrations: (pluginId) =>
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
startPlugin: async (pluginId) => {
begun.push(pluginId);
await gate(pluginId).promise;
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return {
runtime,
begun,
finish: (pluginId: string) => gate(pluginId).resolve(),
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
};
}
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = service.subscribe((notified) => {
if (notified !== pluginId) return;
unsubscribe();
resolve();
});
});
}
describe("PluginService", () => {
it.each([
{ shadow: false, runtime: "subprocess" },
@@ -422,127 +372,15 @@ describe("PluginService", () => {
"startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second },
});
const notifications: { pluginId: string; running: boolean }[] = [];
service.subscribe((pluginId) =>
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
);
const snapshots: string[][] = [];
service.subscribe(() => snapshots.push(catalogIds(service)));
await service.start();
expect(notifications).toEqual([
{ pluginId: "startup-first", running: true },
{ pluginId: "startup-second", running: true },
]);
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
await service.stopAllPlugins();
}, 20_000);
it("starts configured plugins concurrently and publishes them in config order", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
alpha: { source: "directory", path: "/plugins/alpha" },
beta: { source: "directory", path: "/plugins/beta" },
gamma: { source: "directory", path: "/plugins/gamma" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
let started = false;
const starting = (async () => {
await service.start();
started = true;
})();
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
gated.finish("gamma");
const alphaPublished = nextNotification(service, "alpha");
gated.finish("alpha");
await alphaPublished;
expect(notified).toEqual(["alpha"]);
expect(started).toBe(false);
gated.finish("beta");
await starting;
expect(notified).toEqual(["alpha", "beta", "gamma"]);
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
await service.stopAllPlugins();
});
it("starts the other configured plugins when one fails while they are still starting", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
broken: { source: "directory", path: "/plugins/broken" },
healthy: { source: "directory", path: "/plugins/healthy" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
const brokenPublished = nextNotification(service, "broken");
gated.fail("broken", new Error("startup exploded"));
await brokenPublished;
gated.finish("healthy");
await starting;
expect(notified).toEqual(["broken", "healthy"]);
expect(catalogIds(service)).toEqual(["healthy"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "broken", status: "failed", error: "startup exploded" },
{ id: "healthy", status: "running" },
]);
await service.stopAllPlugins();
});
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime({
first: [{ id: "shared-agent", label: "First agent" }],
second: [{ id: "shared-agent", label: "Second agent" }],
});
const service = createService(
home,
{
first: { source: "directory", path: "/plugins/first" },
second: { source: "directory", path: "/plugins/second" },
},
{ runtime: gated.runtime },
);
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
gated.finish("second");
gated.finish("first");
await starting;
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "shared-agent", label: "First agent" },
]);
expect(catalogIds(service)).toEqual(["first"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "first", status: "running" },
{
id: "second",
status: "failed",
error: 'Plugin second cannot register provider ID "shared-agent" twice',
},
]);
await service.stopAllPlugins();
});
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+8 -20
View File
@@ -230,18 +230,11 @@ export class PluginService {
}
});
if (config.pluginsEnabled === true) {
let earlierPublished: Promise<unknown> = Promise.resolve();
await Promise.all(
Object.entries(config.plugins ?? {})
.filter(([, source]) => source.enabled !== false)
.map(async ([pluginId]) => {
const publishTurn = earlierPublished;
const starting = this.startConfigured(pluginId, publishTurn);
earlierPublished = Promise.all([publishTurn, starting]);
await starting;
this.notify(pluginId);
}),
);
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue;
await this.startConfigured(pluginId);
this.notify(pluginId);
}
}
this.configStore.onFieldChange("pluginsEnabled", (value) => {
this.handleGlobalSwitch(value === true);
@@ -567,12 +560,12 @@ export class PluginService {
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
}
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
private async startConfigured(pluginId: string): Promise<void> {
const source = this.configStore.get().plugins?.[pluginId];
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
this.errors.delete(pluginId);
try {
await this.startPlugin(pluginId, source.path, publishTurn);
await this.startPlugin(pluginId, source.path);
} catch (error) {
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
}
@@ -601,13 +594,8 @@ export class PluginService {
);
}
private async startPlugin(
pluginId: string,
sourcePath: string,
publishTurn?: Promise<unknown>,
): Promise<void> {
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
await publishTurn;
try {
await this.publishProviderRegistrations(pluginId, sourcePath);
this.publishUsageSources(pluginId);
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
roots.push(home);
const sources = new ManagedPluginSources(home);
// Own the failure response instead of relying on a closed port and OS connect timing.
const server = createServer((_request, response) => {
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("clone rejected for oauth2:super-secret\n");
const failure = sources.prepareInstall({
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
try {
const failure = sources.prepareInstall({
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
});
await expect(failure).rejects.toThrow("500");
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
});
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
+31 -246
View File
@@ -1,9 +1,7 @@
import { type EventEmitter, once } from "node:events";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import net from "node:net";
import type { Duplex } from "node:stream";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
});
});
interface ProxiedServiceFixture {
interface ForwardedFixture {
upstreamPort: number;
daemonPort: number;
daemon: http.Server;
hostname: string;
close(): Promise<void>;
}
/**
* Runs a real workspace service behind a real daemon listener that serves the
* service proxy for both HTTP and upgrades.
* Runs a real workspace service behind a real daemon listener. The upstream
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
*/
async function startProxiedService(
upstream: http.Server,
options: { upstreamHost?: string; logger?: pino.Logger } = {},
): Promise<ProxiedServiceFixture> {
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
// Upgraded sockets keep the server alive; close() alone would hang.
const sockets = new Set<net.Socket>();
function trackConnections(server: http.Server) {
server.on("connection", (socket: net.Socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
}
trackConnections(upstream);
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
upgradeSockets.push(socket);
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
const serviceProxy = createServiceProxySubsystem({ logger });
const route = serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
@@ -383,51 +390,24 @@ async function startProxiedService(
res.status(404).send("404 Not Found");
});
const daemon = http.createServer(app);
trackConnections(daemon);
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
daemon,
hostname: route.hostname,
async close() {
for (const socket of sockets) socket.destroy();
// Upgraded sockets keep the server alive; close() alone would hang.
for (const socket of upgradeSockets) socket.destroy();
daemon.closeAllConnections();
await new Promise<void>((resolve) => daemon.close(() => resolve()));
upstream.closeAllConnections();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
},
};
}
/**
* The upstream echoes the headers it received so tests can assert what
* actually crossed the proxy, not what a helper returned.
*/
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
upstream.on("upgrade", (req, socket) => {
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
return startProxiedService(upstream, { upstreamHost });
}
function upgradeThroughProxy(
port: number,
host: string,
@@ -671,198 +651,3 @@ describe("service proxy forwarded headers", () => {
}
});
});
function closed(emitter: EventEmitter): Promise<void> {
return new Promise((resolve) => {
emitter.once("close", () => resolve());
});
}
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
function requestThroughProxy(
fixture: ProxiedServiceFixture,
requestPath: string,
): http.ClientRequest {
return http.get({
hostname: "127.0.0.1",
port: fixture.daemonPort,
path: requestPath,
headers: { host: fixture.hostname },
});
}
const STRAY_WARNING_GRACE_MS = 100;
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
const upstream = http.createServer();
upstream.on("upgrade", (_req, socket) => {
socket.on("error", () => socket.destroy());
socket.on("end", () => socket.end());
socket.write(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
);
onUpgrade(socket);
});
return upstream;
}
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
const lines = [
"GET /ws HTTP/1.1",
`Host: ${fixture.hostname}`,
"Upgrade: websocket",
"Connection: Upgrade",
"",
"",
];
socket.write(lines.join("\r\n"));
});
let raw = "";
socket.on("data", (chunk: Buffer) => {
raw += chunk.toString();
if (raw.includes("\r\n\r\n")) resolve(socket);
});
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
socket.on("error", reject);
});
}
describe("service proxy connection teardown", () => {
it("closes the upstream connection when a client leaves an event stream", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("data: open\n\n");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const request = requestThroughProxy(fixture, "/events");
const [response] = (await once(request, "response")) as [http.IncomingMessage];
await once(response, "data");
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the event stream");
} finally {
await fixture.close();
}
}, 5000);
it("aborts the upstream request when a client leaves before the response starts", async () => {
const warnings: unknown[] = [];
const proxyLogger = pino(
{ level: "warn" },
{ write: (line: string) => warnings.push(JSON.parse(line)) },
);
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
try {
const request = requestThroughProxy(fixture, "/slow");
request.on("error", () => undefined);
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
expect(warnings).toEqual([]);
} finally {
await fixture.close();
}
}, 5000);
it("lets a complete response finish without resetting the service", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.write("first;");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
const res = await upstreamResponse.promise;
const upstreamClosed = closed(res);
res.end("second");
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
await within(upstreamClosed, 1000, "the service response never closed");
expect(res.writableFinished).toBe(true);
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
const daemonSide = Promise.withResolvers<Duplex>();
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
(await daemonSide.promise).destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
client.destroy();
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the client disconnects", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
client.destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
} finally {
await fixture.close();
}
}, 5000);
it("closes the client WebSocket when the service closes it", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const clientClosed = closed(client);
(await upstreamSocket.promise).destroy();
await within(clientClosed, 1000, "the client WebSocket is still open");
} finally {
await fixture.close();
}
}, 5000);
});
@@ -342,14 +342,7 @@ function proxyHttpRequest({
proxyRes.pipe(res, { end: true });
},
);
let clientClosed = false;
res.on("close", () => {
if (res.writableFinished) return;
clientClosed = true;
proxyReq.destroy();
});
proxyReq.on("error", (err) => {
if (clientClosed) return;
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable",
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
socket.on("error", () => {
targetSocket.destroy();
});
socket.on("close", () => {
targetSocket.destroy();
});
}
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
+1 -1
View File
@@ -8318,7 +8318,7 @@ export class Session {
},
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
sessionMode: msg.sessionMode,
clearPendingPermissions: false,
clearPendingPermissions: true,
});
deliveryStatus = "queued";
queued();
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
command: process.execPath,
args: [
"-e",
`
const fs = require("node:fs");
const envPath = ${JSON.stringify(envPath)};
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
fs.renameSync(envPath + ".tmp", envPath);
setInterval(() => {}, 1000);
`,
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
],
});
await waitForCondition(() => existsSync(envPath), 10000);
@@ -1,4 +1,3 @@
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
@@ -731,103 +730,3 @@ test("Git event identity retains a symlink's literal target before canonicalizin
rmSync(root, { recursive: true, force: true });
}
});
test.skipIf(process.platform !== "linux")(
"missing folders stop degraded Git polling and explicit refreshes, then recover",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
const cwd = path.join(root, "repo");
const initializeRepo = (branch: string) => {
mkdirSync(cwd);
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"fixture",
],
{ cwd, stdio: "pipe" },
);
};
initializeRepo("main");
// Exceed the real Linux observer's directory limit to enter degraded polling.
// No watcher errors or Git responses are injected.
for (let index = 0; index < 5100; index++) {
mkdirSync(path.join(cwd, `generated-${index}`));
}
const trace = path.join(root, "git.jsonl");
writeFileSync(trace, "");
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
process.env.PASEO_GIT_TRACE_FILE = trace;
let degraded = false;
let suspended = false;
const logger = pino(
{ level: "warn" },
{
write(line) {
const message = JSON.parse(line).msg;
if (message === "Failed to start working tree watcher; using degraded polling")
degraded = true;
if (
message ===
"Workspace folder no longer exists; suspended git observation until it is back"
)
suspended = true;
},
},
);
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
const subscription = service.registerWorkspace({ cwd }, () => {});
const commandCount = async () => {
await flushGitCommandTrace();
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
};
try {
await expect
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
timeout: 15_000,
})
.toBe(true);
rmSync(cwd, { recursive: true, force: true });
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
const commandsWhileMissing = await commandCount();
await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
await service.refresh(cwd);
expect(await commandCount()).toBe(commandsWhileMissing);
await new Promise((resolve) => setTimeout(resolve, 16_000));
expect(await commandCount()).toBe(commandsWhileMissing);
initializeRepo("restored");
const snapshot = await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
} finally {
subscription.unsubscribe();
await service.dispose();
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -244,7 +244,6 @@ function createService(
filterEvents: (events) => events,
verify: vi.fn(async () => {}),
})),
workspaceFolderExists: vi.fn(async () => true),
...overrides,
} as never,
});
@@ -1,7 +1,5 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path, { join } from "node:path";
import type pino from "pino";
@@ -12,14 +10,11 @@ import type {
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
BACKGROUND_GIT_FETCH_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
WORKSPACE_GIT_REFRESH_CONCURRENCY,
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
workspaceFolderExists,
} from "./workspace-git-service.js";
const REPO_CWD = path.resolve("/tmp/repo");
@@ -28,7 +23,6 @@ function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
return logger;
@@ -257,7 +251,7 @@ function createGitHubServiceStub(): ForgeService {
interface CreateServiceTestOptions {
subscribe?: ReturnType<typeof vi.fn>;
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -267,7 +261,6 @@ interface CreateServiceTestOptions {
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
now?: () => Date;
}
@@ -301,7 +294,6 @@ function buildDefaultTestServiceDeps() {
signal: null,
})),
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
workspaceFolderExists: vi.fn(async () => true),
createWatcherLivenessCanary: vi.fn(() => ({
path: "",
filterEvents: (events) => events,
@@ -332,145 +324,6 @@ function createService(options?: CreateServiceTestOptions) {
});
}
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
interface FolderBackedGitOptions {
facts?: (cwd: string) => CheckoutSnapshotFacts;
unwatchableDirectories?: string[];
heldWatch?: { directory: string; release: Promise<void> };
}
function createFolderBackedGit(options?: FolderBackedGitOptions) {
const watchCallbacks = new Map<string, WatchCallback>();
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
throw new Error(`Cannot watch ${directory}`);
}
if (options?.heldWatch?.directory === directory) {
await options.heldWatch.release;
}
watchCallbacks.set(directory, callback);
return createAsyncSubscription();
});
const getCheckoutSnapshotFacts = vi.fn(
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
);
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
isDirty: false,
diffStat: { additions: 1, deletions: 0 },
}));
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
return {
deps: {
subscribe,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitCommand,
runGitFetch,
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
now: () => new Date(),
},
watchCallbackFor(directory: string): WatchCallback {
const callback = watchCallbacks.get(directory);
if (!callback) {
throw new Error(`No watcher was started for ${directory}`);
}
return callback;
},
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
gitCommandsIn(cwd: string): number {
const cwdCommands = [
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitFetch,
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
const commandsRunInCwd = runGitCommand.mock.calls.filter(
([, runOptions]) => runOptions.cwd === cwd,
).length;
return cwdCommands + commandsRunInCwd;
},
};
}
function linkedWorktreeFacts(gitCommonDir: string) {
return (cwd: string): CheckoutSnapshotFacts => ({
...createCheckoutSnapshotFacts(cwd),
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
gitCommonDir,
});
}
const SETTLE_ATTEMPTS = 1_000;
async function settle(assertion: () => void): Promise<void> {
let lastFailure: unknown = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
try {
assertion();
return;
} catch (failure) {
lastFailure = failure;
}
await vi.advanceTimersByTimeAsync(0);
}
throw lastFailure;
}
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
await settle(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
}
async function waitForObservationToSettle(
service: WorkspaceGitServiceImpl,
repositoryWorkspaceLinkCount: number,
): Promise<void> {
await settle(() => {
const metrics = service.getMetrics();
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
});
}
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
async function advanceThroughWorkingTreeRecoveryAttempts(
service: WorkspaceGitServiceImpl,
): Promise<void> {
for (const delayMs of [30_000, 60_000, 120_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
describe("WorkspaceGitServiceImpl", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -1700,460 +1553,3 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
});
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
const temporaryFolders: string[] = [];
async function createTemporaryFolder(): Promise<string> {
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
temporaryFolders.push(folder);
return folder;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
);
});
test("polling stops for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
await service.dispose();
});
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const firstWorktree = await createTemporaryFolder();
const secondWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(firstWorktree, { recursive: true, force: true });
await rm(secondWorktree, { recursive: true, force: true });
const commandsAtDeletion = [
git.gitCommandsIn(firstWorktree),
git.gitCommandsIn(secondWorktree),
];
await advanceThroughDegradedPolls(service);
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
commandsAtDeletion,
);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(2);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const commandsWhileMissing = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const pollsAfterResume = git.pollsOf(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
const commandsWhileMissing = git.gitCommandsIn(workspace);
for (let probe = 0; probe < 10; probe += 1) {
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForRefreshesToSettle(service);
}
service.scheduleRefreshForCwd(workspace);
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const worktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(worktree, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(worktree);
const fetchesBeforeRestore = git.fetchesIn(worktree);
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForObservationToSettle(service, 1);
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
const pollsAfterRestore = git.pollsOf(worktree);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
subscription.unsubscribe();
await service.dispose();
});
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const snapshot = await service.getSnapshot(workspace, {
force: true,
reason: "refresh-button",
});
await waitForObservationToSettle(service, 1);
expect(snapshot.git.isGit).toBe(true);
subscription.unsubscribe();
await service.dispose();
});
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
const workspace = await createTemporaryFolder();
const gitDir = join(workspace, ".git");
await mkdir(gitDir);
const metadataWatchRelease = createDeferred<void>();
const git = createFolderBackedGit({
unwatchableDirectories: [workspace],
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
});
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await settle(() => {
expect(git.watchAttemptsFor(gitDir)).toBe(1);
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
});
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
metadataWatchRelease.resolve();
await waitForObservationToSettle(service, 1);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const diffListener = vi.fn();
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const notificationsAfterWatcherError = diffListener.mock.calls.length;
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughWorkingTreeRecoveryAttempts(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
await mkdir(join(workspace, ".git"), { recursive: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
await vi.advanceTimersByTimeAsync(240_000);
await waitForRefreshesToSettle(service);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
const notificationsAfterResubscribe = diffListener.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
diffWatch.unsubscribe();
workspaceSubscription.unsubscribe();
await service.dispose();
});
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const started = createDeferred<void>();
const release = createDeferred<void>();
let holdNextRead = false;
let factsReads = 0;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
factsReads++;
if (holdNextRead) {
holdNextRead = false;
started.resolve();
await release.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
const queued = service.getSnapshot(workspace, {
force: true,
includeForge: true,
reason: "queued-refresh",
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const readsAtSuspension = factsReads;
const updatesAtSuspension = updates.length;
release.resolve();
expect(await active).toEqual(cached);
expect(await queued).toEqual(cached);
expect(await service.getSnapshot(workspace)).toEqual(cached);
expect(updates).toHaveLength(updatesAtSuspension);
expect(factsReads).toBe(readsAtSuspension);
subscription.unsubscribe();
await service.dispose();
});
test("restoration settles a suspended refresh before reloading facts", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
const started = createDeferred<void>();
let holdNextRead = false;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
if (holdNextRead) {
holdNextRead = false;
started.resolve();
return staleFacts.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const updatesAtSuspension = updates.length;
await mkdir(workspace);
const restored = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "folder-returned",
});
await vi.advanceTimersByTimeAsync(0);
staleFacts.resolve({ isGit: false });
expect(await active).toEqual(cached);
expect((await restored).git.isGit).toBe(true);
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
await waitForObservationToSettle(service, 1);
subscription.unsubscribe();
await service.dispose();
});
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
const folder = await createTemporaryFolder();
const file = join(folder, "file.txt");
await writeFile(file, "");
expect(await workspaceFolderExists(folder)).toBe(true);
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
});
});

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